feat: update package dependencies and enhance routing for email verification and password recovery. Add nodemailer and related types for email handling, and implement new routes for verify-email and forgot-password in the application. Improve sign-in and sign-up forms to support email verification flow.
Browse files- apps/server/package.json +2 -0
- apps/web/public/logo.png +2 -2
- apps/web/src/components/attempt/OptionDisplay.tsx +166 -0
- apps/web/src/components/attempt/QuestionReviewCard.tsx +282 -0
- apps/web/src/components/attempt/ReviewFilterBar.tsx +119 -0
- apps/web/src/components/attempt/SkillBreakdown.tsx +72 -0
- apps/web/src/components/bank/QuestionCard.tsx +2 -2
- apps/web/src/components/generate/TestBlueprintCard.tsx +15 -4
- apps/web/src/components/settings/AccountSettings.tsx +161 -0
- apps/web/src/components/sign-in-form.tsx +18 -6
- apps/web/src/components/sign-up-form.tsx +10 -3
- apps/web/src/components/test/AttemptTestView.tsx +4 -4
- apps/web/src/hooks/use-generation-jobs.ts +33 -3
- apps/web/src/routeTree.gen.ts +42 -0
- apps/web/src/routes/__root.tsx +3 -1
- apps/web/src/routes/attempt.$id.tsx +140 -257
- apps/web/src/routes/forgot-password.tsx +253 -0
- apps/web/src/routes/generate.tsx +23 -6
- apps/web/src/routes/index.tsx +3 -0
- apps/web/src/routes/packages.tsx +132 -15
- apps/web/src/routes/settings.tsx +8 -2
- apps/web/src/routes/verify-email.tsx +193 -0
- bun.lock +15 -0
- packages/api/package.json +4 -0
- packages/api/src/lib/email.ts +50 -0
- packages/api/src/routers/attempt.ts +3 -1
- packages/api/src/routers/index.ts +2 -0
- packages/api/src/routers/package.ts +20 -0
- packages/api/src/routers/verification.ts +171 -0
- packages/auth/src/index.ts +5 -0
- packages/env/src/server.ts +5 -0
- packages/ui/src/components/button.tsx +2 -0
apps/server/package.json
CHANGED
|
@@ -20,11 +20,13 @@
|
|
| 20 |
"better-auth": "catalog:",
|
| 21 |
"dotenv": "catalog:",
|
| 22 |
"hono": "catalog:",
|
|
|
|
| 23 |
"zod": "catalog:"
|
| 24 |
},
|
| 25 |
"devDependencies": {
|
| 26 |
"@labas/config": "workspace:*",
|
| 27 |
"@types/bun": "catalog:",
|
|
|
|
| 28 |
"tsdown": "^0.21.9",
|
| 29 |
"typescript": "catalog:"
|
| 30 |
}
|
|
|
|
| 20 |
"better-auth": "catalog:",
|
| 21 |
"dotenv": "catalog:",
|
| 22 |
"hono": "catalog:",
|
| 23 |
+
"nodemailer": "^8.0.7",
|
| 24 |
"zod": "catalog:"
|
| 25 |
},
|
| 26 |
"devDependencies": {
|
| 27 |
"@labas/config": "workspace:*",
|
| 28 |
"@types/bun": "catalog:",
|
| 29 |
+
"@types/nodemailer": "^8.0.0",
|
| 30 |
"tsdown": "^0.21.9",
|
| 31 |
"typescript": "catalog:"
|
| 32 |
}
|
apps/web/public/logo.png
CHANGED
|
Git LFS Details
|
|
Git LFS Details
|
apps/web/src/components/attempt/OptionDisplay.tsx
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { MaterialIcon } from "@/components/ui/MaterialIcon";
|
| 2 |
+
|
| 3 |
+
const MCQ_FORMATS = [
|
| 4 |
+
"multiple_choice", "synonym", "grammar_in_context", "sentence_completion",
|
| 5 |
+
"reference", "kanji_reading", "particle_choice", "article_case",
|
| 6 |
+
"matching_headings", "matching_information", "summary_completion", "cloze",
|
| 7 |
+
"error_recognition", "text_insertion",
|
| 8 |
+
];
|
| 9 |
+
|
| 10 |
+
interface OptionDisplayProps {
|
| 11 |
+
format: string;
|
| 12 |
+
options: any[];
|
| 13 |
+
correctAnswer: string;
|
| 14 |
+
userAnswer: string;
|
| 15 |
+
}
|
| 16 |
+
|
| 17 |
+
export function OptionDisplay({ format, options, correctAnswer, userAnswer }: OptionDisplayProps) {
|
| 18 |
+
if (!options || options.length === 0) return null;
|
| 19 |
+
|
| 20 |
+
if (format === "matching_pairs") {
|
| 21 |
+
return <MatchingPairsOptions options={options} correctAnswer={correctAnswer} userAnswer={userAnswer} />;
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
if (format === "true_false_not_given") {
|
| 25 |
+
return <TriStateOptions choices={["TRUE", "FALSE", "NOT_GIVEN"]} labels={{ TRUE: "True", FALSE: "False", NOT_GIVEN: "Not Given" }} correctAnswer={correctAnswer} userAnswer={userAnswer} />;
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
if (format === "author_view") {
|
| 29 |
+
return <TriStateOptions choices={["YES", "NO", "NOT_GIVEN"]} labels={{ YES: "Yes", NO: "No", NOT_GIVEN: "Not Given" }} correctAnswer={correctAnswer} userAnswer={userAnswer} />;
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
if (MCQ_FORMATS.includes(format)) {
|
| 33 |
+
return <McqOptions options={options} correctAnswer={correctAnswer} userAnswer={userAnswer} />;
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
return null;
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
function McqOptions({ options, correctAnswer, userAnswer }: { options: { key: string; text: string }[]; correctAnswer: string; userAnswer: string }) {
|
| 40 |
+
const normUser = userAnswer?.trim().toUpperCase() || "";
|
| 41 |
+
const normCorrect = correctAnswer?.trim().toUpperCase() || "";
|
| 42 |
+
const hasAnswer = !!userAnswer && userAnswer !== "Tidak dijawab";
|
| 43 |
+
|
| 44 |
+
return (
|
| 45 |
+
<div className="space-y-2">
|
| 46 |
+
{options.map((opt) => {
|
| 47 |
+
const key = opt.key.trim().toUpperCase();
|
| 48 |
+
const isCorrectOption = key === normCorrect;
|
| 49 |
+
const isChosen = key === normUser && hasAnswer;
|
| 50 |
+
const isWrongChoice = isChosen && !isCorrectOption;
|
| 51 |
+
|
| 52 |
+
let bg = "bg-[var(--oat-light)]";
|
| 53 |
+
let keyBg = "bg-[var(--oat-light)] text-[var(--clay-black)]";
|
| 54 |
+
let border = "border-[var(--oat-border)]";
|
| 55 |
+
let icon = null;
|
| 56 |
+
|
| 57 |
+
if (isWrongChoice) {
|
| 58 |
+
bg = "bg-[var(--pomegranate-100)]";
|
| 59 |
+
keyBg = "bg-[var(--pomegranate-400)] text-[var(--pure-white)]";
|
| 60 |
+
border = "border-[var(--pomegranate-400)]";
|
| 61 |
+
icon = <MaterialIcon name="cancel" className="text-sm text-[var(--pomegranate-400)] shrink-0" />;
|
| 62 |
+
} else if (isCorrectOption) {
|
| 63 |
+
bg = "bg-[var(--matcha-300)]/20";
|
| 64 |
+
keyBg = "bg-[var(--matcha-600)] text-[var(--pure-white)]";
|
| 65 |
+
border = "border-[var(--matcha-400)]";
|
| 66 |
+
icon = <MaterialIcon name="check_circle" className="text-sm text-[var(--matcha-600)] shrink-0" />;
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
+
return (
|
| 70 |
+
<div key={opt.key} className={`flex items-center gap-3 p-3 rounded-[var(--radius-lg)] border-2 ${border} ${bg}`}>
|
| 71 |
+
<span className={`w-8 h-8 min-w-[2rem] min-h-[2rem] rounded-full text-sm font-bold flex items-center justify-center shrink-0 ${keyBg}`}>
|
| 72 |
+
{key || "?"}
|
| 73 |
+
</span>
|
| 74 |
+
<span className="flex-1 text-sm text-[var(--clay-black)]">{opt.text}</span>
|
| 75 |
+
{icon}
|
| 76 |
+
</div>
|
| 77 |
+
);
|
| 78 |
+
})}
|
| 79 |
+
</div>
|
| 80 |
+
);
|
| 81 |
+
}
|
| 82 |
+
|
| 83 |
+
function TriStateOptions({ choices, labels, correctAnswer, userAnswer }: { choices: string[]; labels: Record<string, string>; correctAnswer: string; userAnswer: string }) {
|
| 84 |
+
const normUser = userAnswer?.trim().toUpperCase().replace(/\s+/g, "_") || "";
|
| 85 |
+
const normCorrect = correctAnswer?.trim().toUpperCase() || "";
|
| 86 |
+
const hasAnswer = !!userAnswer && userAnswer !== "Tidak dijawab";
|
| 87 |
+
|
| 88 |
+
return (
|
| 89 |
+
<div className="space-y-2">
|
| 90 |
+
{choices.map((c) => {
|
| 91 |
+
const isCorrectOption = c === normCorrect;
|
| 92 |
+
const isChosen = c === normUser && hasAnswer;
|
| 93 |
+
const isWrongChoice = isChosen && !isCorrectOption;
|
| 94 |
+
|
| 95 |
+
let bg = "bg-[var(--oat-light)]";
|
| 96 |
+
let dot = "border-[var(--oat-border)]";
|
| 97 |
+
let textColor = "text-[var(--warm-charcoal)]";
|
| 98 |
+
|
| 99 |
+
if (isWrongChoice) {
|
| 100 |
+
bg = "bg-[var(--pomegranate-100)]";
|
| 101 |
+
dot = "border-[var(--pomegranate-400)] bg-[var(--pomegranate-400)]";
|
| 102 |
+
textColor = "text-[var(--pomegranate-600)]";
|
| 103 |
+
} else if (isCorrectOption) {
|
| 104 |
+
bg = "bg-[var(--matcha-300)]/20";
|
| 105 |
+
dot = "border-[var(--matcha-600)] bg-[var(--matcha-600)]";
|
| 106 |
+
textColor = "text-[var(--matcha-800)]";
|
| 107 |
+
}
|
| 108 |
+
|
| 109 |
+
return (
|
| 110 |
+
<div key={c} className={`flex items-center gap-3 p-3 rounded-[var(--radius-lg)] border-2 border-[var(--oat-border)] ${bg}`}>
|
| 111 |
+
<span className={`w-5 h-5 rounded-full border-2 ${dot} flex items-center justify-center shrink-0`}>
|
| 112 |
+
{isChosen || isCorrectOption ? <span className="w-2 h-2 rounded-full bg-white" /> : null}
|
| 113 |
+
</span>
|
| 114 |
+
<span className={`text-sm font-semibold ${textColor}`}>{labels[c]}</span>
|
| 115 |
+
</div>
|
| 116 |
+
);
|
| 117 |
+
})}
|
| 118 |
+
</div>
|
| 119 |
+
);
|
| 120 |
+
}
|
| 121 |
+
|
| 122 |
+
function MatchingPairsOptions({ options, correctAnswer, userAnswer }: { options: { left: string; right?: string }[]; correctAnswer: string; userAnswer: string }) {
|
| 123 |
+
const parseMapping = (s: string): Map<string, string> => {
|
| 124 |
+
const map = new Map();
|
| 125 |
+
if (!s) return map;
|
| 126 |
+
s.split(",").forEach((pair) => {
|
| 127 |
+
const [k, v] = pair.split(":").map((x) => x.trim());
|
| 128 |
+
if (k && v) map.set(k, v);
|
| 129 |
+
});
|
| 130 |
+
return map;
|
| 131 |
+
};
|
| 132 |
+
|
| 133 |
+
const correctMap = parseMapping(correctAnswer);
|
| 134 |
+
const userMap = parseMapping(userAnswer);
|
| 135 |
+
|
| 136 |
+
return (
|
| 137 |
+
<div className="space-y-2">
|
| 138 |
+
{options.map((opt) => {
|
| 139 |
+
const matchedCorrect = correctMap.get(opt.left) || "";
|
| 140 |
+
const matchedUser = userMap.get(opt.left) || "";
|
| 141 |
+
const isMatchCorrect = matchedUser === matchedCorrect;
|
| 142 |
+
const hasUserAnswer = !!matchedUser;
|
| 143 |
+
|
| 144 |
+
return (
|
| 145 |
+
<div key={opt.left} className="flex items-center gap-3 p-3 rounded-[var(--radius-lg)] border-2 border-[var(--oat-border)] bg-[var(--oat-light)]">
|
| 146 |
+
<span className="w-8 h-8 rounded-full text-sm font-bold flex items-center justify-center shrink-0 bg-[var(--oat-light)] text-[var(--clay-black)] border-2 border-[var(--oat-border)]">
|
| 147 |
+
{opt.left.charAt(0).toUpperCase()}
|
| 148 |
+
</span>
|
| 149 |
+
<span className="flex-1 text-sm text-[var(--clay-black)]">{opt.left}</span>
|
| 150 |
+
<span className="text-[var(--warm-silver)]">→</span>
|
| 151 |
+
{hasUserAnswer && (
|
| 152 |
+
<span className={`text-sm font-medium px-3 py-1.5 rounded-[var(--radius-md)] ${isMatchCorrect ? "bg-[var(--matcha-300)]/20 text-[var(--matcha-700)]" : "bg-[var(--pomegranate-100)] text-[var(--pomegranate-600)] line-through"}`}>
|
| 153 |
+
{matchedUser}
|
| 154 |
+
</span>
|
| 155 |
+
)}
|
| 156 |
+
{!isMatchCorrect && matchedCorrect && (
|
| 157 |
+
<span className="text-sm font-medium px-3 py-1.5 rounded-[var(--radius-md)] bg-[var(--matcha-300)]/20 text-[var(--matcha-700)]">
|
| 158 |
+
{matchedCorrect}
|
| 159 |
+
</span>
|
| 160 |
+
)}
|
| 161 |
+
</div>
|
| 162 |
+
);
|
| 163 |
+
})}
|
| 164 |
+
</div>
|
| 165 |
+
);
|
| 166 |
+
}
|
apps/web/src/components/attempt/QuestionReviewCard.tsx
ADDED
|
@@ -0,0 +1,282 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useState } from "react";
|
| 2 |
+
import { useQuery, useMutation } from "@tanstack/react-query";
|
| 3 |
+
import { trpc } from "@/utils/trpc";
|
| 4 |
+
import { Button } from "@labas/ui/components/button";
|
| 5 |
+
import { Input } from "@labas/ui/components/input";
|
| 6 |
+
import { MaterialIcon } from "@/components/ui/MaterialIcon";
|
| 7 |
+
import { formatTime } from "@/lib/time";
|
| 8 |
+
import { OptionDisplay } from "./OptionDisplay";
|
| 9 |
+
|
| 10 |
+
interface QuestionReviewCardProps {
|
| 11 |
+
q: any;
|
| 12 |
+
secIdx: number;
|
| 13 |
+
qIdx: number;
|
| 14 |
+
ans: any;
|
| 15 |
+
userId?: string;
|
| 16 |
+
isExpanded: boolean;
|
| 17 |
+
onToggleExpand: () => void;
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
+
export function QuestionReviewCard({
|
| 21 |
+
q,
|
| 22 |
+
secIdx,
|
| 23 |
+
qIdx,
|
| 24 |
+
ans,
|
| 25 |
+
userId,
|
| 26 |
+
isExpanded,
|
| 27 |
+
onToggleExpand,
|
| 28 |
+
}: QuestionReviewCardProps) {
|
| 29 |
+
const isCorrect = ans?.isCorrect;
|
| 30 |
+
const userAnswer = ans?.userAnswer ?? "Tidak dijawab";
|
| 31 |
+
const isOwner = q.creatorUserId === userId;
|
| 32 |
+
const hasOptions = Array.isArray(q.options) && q.options.length > 0;
|
| 33 |
+
|
| 34 |
+
const [isEditing, setIsEditing] = useState(false);
|
| 35 |
+
const [editPassage, setEditPassage] = useState(q.passageText ?? "");
|
| 36 |
+
const [editCorrectAnswer, setEditCorrectAnswer] = useState(q.correctAnswer ?? "");
|
| 37 |
+
const [editExplanation, setEditExplanation] = useState(q.explanation ?? "");
|
| 38 |
+
|
| 39 |
+
const feedbackQuery = useQuery(
|
| 40 |
+
trpc.feedback.getQuestionFeedback.queryOptions(
|
| 41 |
+
{ questionId: q.id },
|
| 42 |
+
{ enabled: !!q.id },
|
| 43 |
+
),
|
| 44 |
+
);
|
| 45 |
+
|
| 46 |
+
const voteMutation = useMutation({
|
| 47 |
+
...trpc.feedback.voteQuestion.mutationOptions(),
|
| 48 |
+
onSuccess: () => feedbackQuery.refetch(),
|
| 49 |
+
});
|
| 50 |
+
|
| 51 |
+
const updateQuestionMutation = useMutation({
|
| 52 |
+
...trpc.question.update.mutationOptions(),
|
| 53 |
+
onSuccess: () => {
|
| 54 |
+
setIsEditing(false);
|
| 55 |
+
window.location.reload();
|
| 56 |
+
},
|
| 57 |
+
});
|
| 58 |
+
|
| 59 |
+
const handleSaveEdit = () => {
|
| 60 |
+
updateQuestionMutation.mutate({
|
| 61 |
+
id: q.id,
|
| 62 |
+
passageText: editPassage,
|
| 63 |
+
correctAnswer: editCorrectAnswer,
|
| 64 |
+
explanation: editExplanation,
|
| 65 |
+
});
|
| 66 |
+
};
|
| 67 |
+
|
| 68 |
+
return (
|
| 69 |
+
<div
|
| 70 |
+
className={`bg-[var(--pure-white)] border-2 rounded-[var(--radius-xl)] clay-shadow transition-all ${
|
| 71 |
+
isCorrect === true
|
| 72 |
+
? "border-[var(--matcha-400)]"
|
| 73 |
+
: isCorrect === false
|
| 74 |
+
? "border-[var(--pomegranate-400)]"
|
| 75 |
+
: "border-[var(--oat-border)]"
|
| 76 |
+
}`}
|
| 77 |
+
>
|
| 78 |
+
{/* Header — always visible, clickable to expand */}
|
| 79 |
+
<button
|
| 80 |
+
onClick={onToggleExpand}
|
| 81 |
+
className="w-full text-left p-4 flex items-start gap-3 cursor-pointer"
|
| 82 |
+
>
|
| 83 |
+
<span
|
| 84 |
+
className={`w-8 h-8 rounded-full text-xs flex items-center justify-center font-bold shrink-0 ${
|
| 85 |
+
isCorrect === true
|
| 86 |
+
? "bg-[var(--matcha-600)] text-[var(--pure-white)]"
|
| 87 |
+
: isCorrect === false
|
| 88 |
+
? "bg-[var(--pomegranate-400)] text-[var(--pure-white)]"
|
| 89 |
+
: "bg-[var(--oat-light)] text-[var(--warm-charcoal)]"
|
| 90 |
+
}`}
|
| 91 |
+
>
|
| 92 |
+
{secIdx + 1}.{qIdx + 1}
|
| 93 |
+
</span>
|
| 94 |
+
<div className="flex-1 min-w-0">
|
| 95 |
+
<p className="text-sm font-medium text-[var(--clay-black)] truncate">
|
| 96 |
+
{q.questionText}
|
| 97 |
+
</p>
|
| 98 |
+
<div className="flex items-center gap-2 mt-1">
|
| 99 |
+
<span className="text-[10px] px-1.5 py-0.5 rounded bg-[var(--oat-light)] text-[var(--warm-charcoal)]">
|
| 100 |
+
{q.format.replace(/_/g, " ")}
|
| 101 |
+
</span>
|
| 102 |
+
{ans?.timeSpentSec != null && (
|
| 103 |
+
<span className="text-[10px] text-[var(--warm-silver)] flex items-center gap-0.5">
|
| 104 |
+
<MaterialIcon name="timer" className="text-[10px]" />
|
| 105 |
+
{formatTime(ans.timeSpentSec)}
|
| 106 |
+
</span>
|
| 107 |
+
)}
|
| 108 |
+
</div>
|
| 109 |
+
</div>
|
| 110 |
+
<span className="shrink-0 flex items-center gap-1.5 text-xs text-[var(--warm-silver)]">
|
| 111 |
+
{isCorrect === true && (
|
| 112 |
+
<MaterialIcon name="check_circle" className="text-sm text-[var(--matcha-600)]" />
|
| 113 |
+
)}
|
| 114 |
+
{isCorrect === false && (
|
| 115 |
+
<MaterialIcon name="cancel" className="text-sm text-[var(--pomegranate-400)]" />
|
| 116 |
+
)}
|
| 117 |
+
<MaterialIcon
|
| 118 |
+
name={isExpanded ? "expand_less" : "expand_more"}
|
| 119 |
+
className="text-sm"
|
| 120 |
+
/>
|
| 121 |
+
</span>
|
| 122 |
+
</button>
|
| 123 |
+
|
| 124 |
+
{/* Expanded content */}
|
| 125 |
+
{isExpanded && (
|
| 126 |
+
<div className="px-4 pb-4 pl-11 space-y-3">
|
| 127 |
+
{/* Passage Text */}
|
| 128 |
+
{q.passageText && (
|
| 129 |
+
<div className="bg-[var(--oat-light)] rounded-[var(--radius-lg)] p-3 text-sm text-[var(--warm-charcoal)] whitespace-pre-wrap leading-relaxed">
|
| 130 |
+
<span className="font-semibold text-[var(--clay-black)] block mb-1">Teks Bacaan:</span>
|
| 131 |
+
{q.passageText}
|
| 132 |
+
</div>
|
| 133 |
+
)}
|
| 134 |
+
|
| 135 |
+
{/* Interactive Option Display */}
|
| 136 |
+
{hasOptions && (
|
| 137 |
+
<OptionDisplay
|
| 138 |
+
format={q.format}
|
| 139 |
+
options={q.options}
|
| 140 |
+
correctAnswer={q.correctAnswer}
|
| 141 |
+
userAnswer={userAnswer}
|
| 142 |
+
/>
|
| 143 |
+
)}
|
| 144 |
+
|
| 145 |
+
{/* Owner Inline Edit Form */}
|
| 146 |
+
{isEditing && isOwner && (
|
| 147 |
+
<div className="space-y-3 bg-[var(--badge-blue-bg)] rounded-[var(--radius-lg)] p-4 border-2 border-[var(--badge-blue-bg)]">
|
| 148 |
+
<p className="text-sm font-semibold text-[var(--badge-blue-text)] flex items-center gap-2">
|
| 149 |
+
<MaterialIcon name="edit_note" className="text-sm" />
|
| 150 |
+
Koreksi Soal
|
| 151 |
+
</p>
|
| 152 |
+
<div>
|
| 153 |
+
<label className="text-xs font-medium text-[var(--warm-charcoal)] mb-1 block">Teks Bacaan</label>
|
| 154 |
+
<textarea
|
| 155 |
+
value={editPassage}
|
| 156 |
+
onChange={(e) => setEditPassage(e.target.value)}
|
| 157 |
+
className="w-full min-h-[80px] p-2 rounded-[var(--radius-md)] border-2 border-[var(--oat-border)] bg-[var(--pure-white)] text-sm text-[var(--clay-black)] resize-y"
|
| 158 |
+
/>
|
| 159 |
+
</div>
|
| 160 |
+
<div>
|
| 161 |
+
<label className="text-xs font-medium text-[var(--warm-charcoal)] mb-1 block">Jawaban Benar</label>
|
| 162 |
+
<Input
|
| 163 |
+
value={editCorrectAnswer}
|
| 164 |
+
onChange={(e) => setEditCorrectAnswer(e.target.value)}
|
| 165 |
+
className="h-9 text-sm"
|
| 166 |
+
/>
|
| 167 |
+
</div>
|
| 168 |
+
<div>
|
| 169 |
+
<label className="text-xs font-medium text-[var(--warm-charcoal)] mb-1 block">Penjelasan</label>
|
| 170 |
+
<textarea
|
| 171 |
+
value={editExplanation}
|
| 172 |
+
onChange={(e) => setEditExplanation(e.target.value)}
|
| 173 |
+
className="w-full min-h-[60px] p-2 rounded-[var(--radius-md)] border-2 border-[var(--oat-border)] bg-[var(--pure-white)] text-sm text-[var(--clay-black)] resize-y"
|
| 174 |
+
/>
|
| 175 |
+
</div>
|
| 176 |
+
<div className="flex gap-2">
|
| 177 |
+
<Button
|
| 178 |
+
onClick={handleSaveEdit}
|
| 179 |
+
disabled={updateQuestionMutation.isPending}
|
| 180 |
+
className="h-9 text-sm bg-[var(--clay-black)] text-[var(--pure-white)] hover:bg-[var(--warm-charcoal)] rounded-[var(--radius-lg)]"
|
| 181 |
+
>
|
| 182 |
+
<MaterialIcon name="save" className="text-sm mr-1" />
|
| 183 |
+
Simpan
|
| 184 |
+
</Button>
|
| 185 |
+
<Button
|
| 186 |
+
variant="outline"
|
| 187 |
+
onClick={() => setIsEditing(false)}
|
| 188 |
+
className="h-9 text-sm rounded-[var(--radius-lg)] border-2 border-[var(--oat-border)]"
|
| 189 |
+
>
|
| 190 |
+
Batal
|
| 191 |
+
</Button>
|
| 192 |
+
</div>
|
| 193 |
+
</div>
|
| 194 |
+
)}
|
| 195 |
+
|
| 196 |
+
{/* Answer Comparison */}
|
| 197 |
+
<div className="flex flex-wrap gap-4 text-sm">
|
| 198 |
+
<div>
|
| 199 |
+
<span className="text-[var(--warm-silver)]">Jawaban Anda:</span>{" "}
|
| 200 |
+
<span
|
| 201 |
+
className={`font-semibold ${
|
| 202 |
+
isCorrect === true
|
| 203 |
+
? "text-[var(--matcha-700)]"
|
| 204 |
+
: isCorrect === false
|
| 205 |
+
? "text-[var(--pomegranate-600)]"
|
| 206 |
+
: "text-[var(--warm-charcoal)]"
|
| 207 |
+
}`}
|
| 208 |
+
>
|
| 209 |
+
{userAnswer}
|
| 210 |
+
</span>
|
| 211 |
+
</div>
|
| 212 |
+
{ans?.partialScore != null && ans?.partialScore < 100 && (
|
| 213 |
+
<div>
|
| 214 |
+
<span className="text-[var(--warm-silver)]">Skor Parsial:</span>{" "}
|
| 215 |
+
<span className="font-semibold text-[var(--lemon-700)]">
|
| 216 |
+
{ans.partialScore}%
|
| 217 |
+
</span>
|
| 218 |
+
</div>
|
| 219 |
+
)}
|
| 220 |
+
{(isCorrect === false || isCorrect === null) && (
|
| 221 |
+
<div>
|
| 222 |
+
<span className="text-[var(--warm-silver)]">Jawaban Benar:</span>{" "}
|
| 223 |
+
<span className="font-semibold text-[var(--matcha-700)]">
|
| 224 |
+
{q.correctAnswer}
|
| 225 |
+
</span>
|
| 226 |
+
</div>
|
| 227 |
+
)}
|
| 228 |
+
{ans?.timeSpentSec != null && (
|
| 229 |
+
<div className="flex items-center gap-1 text-[var(--warm-silver)]">
|
| 230 |
+
<MaterialIcon name="timer" className="text-xs" />
|
| 231 |
+
<span>{formatTime(ans.timeSpentSec)}</span>
|
| 232 |
+
</div>
|
| 233 |
+
)}
|
| 234 |
+
</div>
|
| 235 |
+
|
| 236 |
+
{/* Explanation */}
|
| 237 |
+
{q.explanation && !isEditing && (
|
| 238 |
+
<div className="bg-[var(--oat-light)] rounded-[var(--radius-lg)] p-3 text-sm text-[var(--warm-charcoal)]">
|
| 239 |
+
<span className="font-semibold text-[var(--clay-black)]">Penjelasan:</span>{" "}
|
| 240 |
+
{q.explanation}
|
| 241 |
+
</div>
|
| 242 |
+
)}
|
| 243 |
+
|
| 244 |
+
{/* Thumbs Feedback */}
|
| 245 |
+
<div className="flex items-center gap-3 pt-1">
|
| 246 |
+
<button
|
| 247 |
+
onClick={() => voteMutation.mutate({ questionId: q.id, type: "up" })}
|
| 248 |
+
className={`flex items-center gap-1 px-3 py-1.5 rounded-full text-xs font-medium transition-all ${
|
| 249 |
+
feedbackQuery.data?.myFeedback === "up"
|
| 250 |
+
? "bg-[var(--matcha-300)] text-[var(--matcha-800)]"
|
| 251 |
+
: "bg-[var(--oat-light)] text-[var(--warm-charcoal)] hover:bg-[var(--matcha-300)] hover:text-[var(--matcha-800)]"
|
| 252 |
+
}`}
|
| 253 |
+
>
|
| 254 |
+
<MaterialIcon name="thumb_up" className="text-sm" />
|
| 255 |
+
<span>{feedbackQuery.data?.up ?? 0}</span>
|
| 256 |
+
</button>
|
| 257 |
+
<button
|
| 258 |
+
onClick={() => voteMutation.mutate({ questionId: q.id, type: "down" })}
|
| 259 |
+
className={`flex items-center gap-1 px-3 py-1.5 rounded-full text-xs font-medium transition-all ${
|
| 260 |
+
feedbackQuery.data?.myFeedback === "down"
|
| 261 |
+
? "bg-[var(--pomegranate-100)] text-[var(--pomegranate-600)]"
|
| 262 |
+
: "bg-[var(--oat-light)] text-[var(--warm-charcoal)] hover:bg-[var(--pomegranate-100)] hover:text-[var(--pomegranate-600)]"
|
| 263 |
+
}`}
|
| 264 |
+
>
|
| 265 |
+
<MaterialIcon name="thumb_down" className="text-sm" />
|
| 266 |
+
<span>{feedbackQuery.data?.down ?? 0}</span>
|
| 267 |
+
</button>
|
| 268 |
+
{isOwner && (
|
| 269 |
+
<button
|
| 270 |
+
onClick={() => setIsEditing(true)}
|
| 271 |
+
className="ml-auto flex items-center gap-1 px-3 py-1.5 rounded-full text-xs font-medium bg-[var(--oat-light)] text-[var(--warm-charcoal)] hover:bg-[var(--badge-blue-bg)] hover:text-[var(--badge-blue-text)] transition-all"
|
| 272 |
+
>
|
| 273 |
+
<MaterialIcon name="edit" className="text-sm" />
|
| 274 |
+
Koreksi
|
| 275 |
+
</button>
|
| 276 |
+
)}
|
| 277 |
+
</div>
|
| 278 |
+
</div>
|
| 279 |
+
)}
|
| 280 |
+
</div>
|
| 281 |
+
);
|
| 282 |
+
}
|
apps/web/src/components/attempt/ReviewFilterBar.tsx
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useMemo } from "react";
|
| 2 |
+
import { MaterialIcon } from "@/components/ui/MaterialIcon";
|
| 3 |
+
|
| 4 |
+
type FilterStatus = "all" | "wrong" | "correct" | "marked";
|
| 5 |
+
|
| 6 |
+
interface ReviewFilterBarProps {
|
| 7 |
+
filterStatus: FilterStatus;
|
| 8 |
+
setFilterStatus: (v: FilterStatus) => void;
|
| 9 |
+
filterSkills: string[];
|
| 10 |
+
setFilterSkills: (v: string[]) => void;
|
| 11 |
+
questions: Array<{ skillTags: string[]; isCorrect: boolean | null }>;
|
| 12 |
+
markedCount: number;
|
| 13 |
+
allExpanded: boolean;
|
| 14 |
+
onToggleAllExpanded: () => void;
|
| 15 |
+
}
|
| 16 |
+
|
| 17 |
+
export function ReviewFilterBar({
|
| 18 |
+
filterStatus,
|
| 19 |
+
setFilterStatus,
|
| 20 |
+
filterSkills,
|
| 21 |
+
setFilterSkills,
|
| 22 |
+
questions,
|
| 23 |
+
markedCount,
|
| 24 |
+
allExpanded,
|
| 25 |
+
onToggleAllExpanded,
|
| 26 |
+
}: ReviewFilterBarProps) {
|
| 27 |
+
const counts = useMemo(() => {
|
| 28 |
+
const total = questions.length;
|
| 29 |
+
const wrong = questions.filter((q) => q.isCorrect === false).length;
|
| 30 |
+
const correct = questions.filter((q) => q.isCorrect === true).length;
|
| 31 |
+
return { total, wrong, correct, marked: markedCount };
|
| 32 |
+
}, [questions, markedCount]);
|
| 33 |
+
|
| 34 |
+
const allSkillTags = useMemo(() => {
|
| 35 |
+
const tags = new Set<string>();
|
| 36 |
+
for (const q of questions) {
|
| 37 |
+
if (q.skillTags) q.skillTags.forEach((t) => tags.add(t));
|
| 38 |
+
}
|
| 39 |
+
return Array.from(tags);
|
| 40 |
+
}, [questions]);
|
| 41 |
+
|
| 42 |
+
const statusFilters: { id: FilterStatus; label: string; count: number; icon: string }[] = [
|
| 43 |
+
{ id: "all", label: "Semua", count: counts.total, icon: "list" },
|
| 44 |
+
{ id: "wrong", label: "Salah", count: counts.wrong, icon: "cancel" },
|
| 45 |
+
{ id: "correct", label: "Benar", count: counts.correct, icon: "check_circle" },
|
| 46 |
+
{ id: "marked", label: "Parsial", count: counts.marked, icon: "pending" },
|
| 47 |
+
];
|
| 48 |
+
|
| 49 |
+
const toggleSkill = (skill: string) => {
|
| 50 |
+
setFilterSkills(
|
| 51 |
+
filterSkills.includes(skill)
|
| 52 |
+
? filterSkills.filter((s) => s !== skill)
|
| 53 |
+
: [...filterSkills, skill],
|
| 54 |
+
);
|
| 55 |
+
};
|
| 56 |
+
|
| 57 |
+
return (
|
| 58 |
+
<div className="sticky top-0 z-10 bg-[var(--warm-cream)] pb-4 pt-2 space-y-3">
|
| 59 |
+
{/* Status filter chips */}
|
| 60 |
+
<div className="flex flex-wrap gap-2">
|
| 61 |
+
{statusFilters.map(({ id, label, count, icon }) => (
|
| 62 |
+
<button
|
| 63 |
+
key={id}
|
| 64 |
+
onClick={() => setFilterStatus(id)}
|
| 65 |
+
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-medium transition-all ${
|
| 66 |
+
filterStatus === id
|
| 67 |
+
? "bg-[var(--clay-black)] text-[var(--pure-white)]"
|
| 68 |
+
: "bg-[var(--pure-white)] text-[var(--warm-charcoal)] border-2 border-[var(--oat-border)] hover:bg-[var(--oat-light)]"
|
| 69 |
+
}`}
|
| 70 |
+
>
|
| 71 |
+
<MaterialIcon name={icon} className="text-xs" />
|
| 72 |
+
{label}
|
| 73 |
+
<span className={`text-[10px] px-1.5 py-0.5 rounded-full ${
|
| 74 |
+
filterStatus === id ? "bg-white/20" : "bg-[var(--oat-light)]"
|
| 75 |
+
}`}>
|
| 76 |
+
{count}
|
| 77 |
+
</span>
|
| 78 |
+
</button>
|
| 79 |
+
))}
|
| 80 |
+
</div>
|
| 81 |
+
|
| 82 |
+
{/* Skill tag filters */}
|
| 83 |
+
{allSkillTags.length > 0 && (
|
| 84 |
+
<div className="flex flex-wrap gap-1.5">
|
| 85 |
+
{allSkillTags.map((tag) => {
|
| 86 |
+
const isActive = filterSkills.includes(tag);
|
| 87 |
+
return (
|
| 88 |
+
<button
|
| 89 |
+
key={tag}
|
| 90 |
+
onClick={() => toggleSkill(tag)}
|
| 91 |
+
className={`rounded-full text-[10px] font-medium transition-all ${
|
| 92 |
+
isActive
|
| 93 |
+
? "bg-[var(--matcha-300)] text-[var(--matcha-800)] px-3 py-1.5"
|
| 94 |
+
: "bg-[var(--oat-light)] text-[var(--warm-charcoal)] hover:bg-[var(--matcha-300)] px-2.5 py-1"
|
| 95 |
+
}`}
|
| 96 |
+
>
|
| 97 |
+
<span className="inline-flex items-center gap-1">
|
| 98 |
+
{tag}
|
| 99 |
+
{isActive && <MaterialIcon name="close" className="text-[10px]" />}
|
| 100 |
+
</span>
|
| 101 |
+
</button>
|
| 102 |
+
);
|
| 103 |
+
})}
|
| 104 |
+
</div>
|
| 105 |
+
)}
|
| 106 |
+
|
| 107 |
+
{/* Expand/Collapse All */}
|
| 108 |
+
<div className="flex justify-end">
|
| 109 |
+
<button
|
| 110 |
+
onClick={onToggleAllExpanded}
|
| 111 |
+
className="flex items-center gap-1 text-xs font-medium text-[var(--matcha-600)] hover:text-[var(--matcha-800)] transition-colors"
|
| 112 |
+
>
|
| 113 |
+
<MaterialIcon name={allExpanded ? "unfold_less" : "unfold_more"} className="text-xs" />
|
| 114 |
+
{allExpanded ? "Tutup Semua" : "Buka Semua"}
|
| 115 |
+
</button>
|
| 116 |
+
</div>
|
| 117 |
+
</div>
|
| 118 |
+
);
|
| 119 |
+
}
|
apps/web/src/components/attempt/SkillBreakdown.tsx
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useMemo } from "react";
|
| 2 |
+
import { MaterialIcon } from "@/components/ui/MaterialIcon";
|
| 3 |
+
|
| 4 |
+
interface SkillBreakdownProps {
|
| 5 |
+
questions: Array<{
|
| 6 |
+
skillTags: string[];
|
| 7 |
+
isCorrect: boolean | null;
|
| 8 |
+
}>;
|
| 9 |
+
onSkillClick: (skill: string) => void;
|
| 10 |
+
activeSkills: string[];
|
| 11 |
+
}
|
| 12 |
+
|
| 13 |
+
export function SkillBreakdown({ questions, onSkillClick, activeSkills }: SkillBreakdownProps) {
|
| 14 |
+
const skillStats = useMemo(() => {
|
| 15 |
+
const stats = new Map<string, { total: number; correct: number }>();
|
| 16 |
+
for (const q of questions) {
|
| 17 |
+
if (!q.skillTags) continue;
|
| 18 |
+
for (const tag of q.skillTags) {
|
| 19 |
+
const prev = stats.get(tag) ?? { total: 0, correct: 0 };
|
| 20 |
+
prev.total++;
|
| 21 |
+
if (q.isCorrect) prev.correct++;
|
| 22 |
+
stats.set(tag, prev);
|
| 23 |
+
}
|
| 24 |
+
}
|
| 25 |
+
return Array.from(stats.entries())
|
| 26 |
+
.map(([skill, { total, correct }]) => ({
|
| 27 |
+
skill,
|
| 28 |
+
total,
|
| 29 |
+
correct,
|
| 30 |
+
pct: total > 0 ? Math.round((correct / total) * 100) : 0,
|
| 31 |
+
}))
|
| 32 |
+
.sort((a, b) => a.pct - b.pct);
|
| 33 |
+
}, [questions]);
|
| 34 |
+
|
| 35 |
+
if (skillStats.length === 0) return null;
|
| 36 |
+
|
| 37 |
+
return (
|
| 38 |
+
<div className="space-y-3">
|
| 39 |
+
{skillStats.map(({ skill, total, correct, pct }) => {
|
| 40 |
+
const isActive = activeSkills.includes(skill);
|
| 41 |
+
const barColor = pct >= 80 ? "bg-[var(--matcha-600)]" : pct >= 50 ? "bg-[var(--lemon-500)]" : "bg-[var(--pomegranate-400)]";
|
| 42 |
+
|
| 43 |
+
return (
|
| 44 |
+
<button
|
| 45 |
+
key={skill}
|
| 46 |
+
onClick={() => onSkillClick(skill)}
|
| 47 |
+
className={`w-full text-left transition-all rounded-[var(--radius-lg)] p-3 ${
|
| 48 |
+
isActive ? "bg-[var(--matcha-300)]/10 ring-2 ring-[var(--matcha-600)]" : "hover:bg-[var(--oat-light)]"
|
| 49 |
+
}`}
|
| 50 |
+
>
|
| 51 |
+
<div className="flex items-center justify-between mb-1.5">
|
| 52 |
+
<span className="text-sm font-semibold text-[var(--clay-black)] flex items-center gap-1.5">
|
| 53 |
+
{skill}
|
| 54 |
+
{isActive && <MaterialIcon name="filter_alt" className="text-xs text-[var(--matcha-600)]" />}
|
| 55 |
+
</span>
|
| 56 |
+
<span className="text-xs font-medium text-[var(--warm-charcoal)]">{correct}/{total}</span>
|
| 57 |
+
</div>
|
| 58 |
+
<div className="w-full h-3 bg-[var(--oat-light)] rounded-full overflow-hidden">
|
| 59 |
+
<div
|
| 60 |
+
className={`h-full rounded-full transition-all duration-500 ${barColor}`}
|
| 61 |
+
style={{ width: `${pct}%` }}
|
| 62 |
+
/>
|
| 63 |
+
</div>
|
| 64 |
+
<div className="flex justify-between mt-0.5">
|
| 65 |
+
<span className="text-[10px] text-[var(--warm-silver)]">{pct}% benar</span>
|
| 66 |
+
</div>
|
| 67 |
+
</button>
|
| 68 |
+
);
|
| 69 |
+
})}
|
| 70 |
+
</div>
|
| 71 |
+
);
|
| 72 |
+
}
|
apps/web/src/components/bank/QuestionCard.tsx
CHANGED
|
@@ -55,10 +55,10 @@ export function QuestionCard({
|
|
| 55 |
{selected ? "Terpilih" : "Pilih"}
|
| 56 |
</span>
|
| 57 |
)}
|
| 58 |
-
<span className="px-2.5 py-1 rounded-full bg-[var(--matcha-300)] text-[var(--matcha-800)] text-xs font-semibold">
|
| 59 |
{q.examTypeName}
|
| 60 |
</span>
|
| 61 |
-
<span className="px-2.5 py-1 rounded-full bg-[var(--slushie-500)]/20 text-[var(--slushie-800)] text-xs font-semibold">
|
| 62 |
{q.sectionTypeName}
|
| 63 |
</span>
|
| 64 |
{disabled && (
|
|
|
|
| 55 |
{selected ? "Terpilih" : "Pilih"}
|
| 56 |
</span>
|
| 57 |
)}
|
| 58 |
+
<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">
|
| 59 |
{q.examTypeName}
|
| 60 |
</span>
|
| 61 |
+
<span className="inline-flex items-center px-2.5 py-1 rounded-full bg-[var(--slushie-500)]/20 text-[var(--slushie-800)] text-xs font-semibold leading-none whitespace-nowrap">
|
| 62 |
{q.sectionTypeName}
|
| 63 |
</span>
|
| 64 |
{disabled && (
|
apps/web/src/components/generate/TestBlueprintCard.tsx
CHANGED
|
@@ -17,6 +17,7 @@ interface TestBlueprintCardProps {
|
|
| 17 |
hasKey: boolean;
|
| 18 |
error: string | null;
|
| 19 |
onGenerate: () => void;
|
|
|
|
| 20 |
}
|
| 21 |
|
| 22 |
export function TestBlueprintCard({
|
|
@@ -34,6 +35,7 @@ export function TestBlueprintCard({
|
|
| 34 |
error,
|
| 35 |
onGenerate,
|
| 36 |
onDismissError,
|
|
|
|
| 37 |
}: TestBlueprintCardProps & { onDismissError?: () => void }) {
|
| 38 |
const sectionNames = selectedSections
|
| 39 |
.map((id) => SECTIONS.find((s) => s.id === id)?.name)
|
|
@@ -105,11 +107,14 @@ export function TestBlueprintCard({
|
|
| 105 |
{/* Mode Toggle */}
|
| 106 |
<div className="flex gap-1 p-1 rounded-[var(--radius-lg)] bg-[var(--oat-light)]">
|
| 107 |
<button
|
| 108 |
-
onClick={() => setMode("quick")}
|
|
|
|
| 109 |
className={`flex-1 py-2.5 px-3 rounded-[var(--radius-md)] text-sm font-semibold transition-all flex items-center justify-center gap-2 min-h-[44px] ${
|
| 110 |
-
|
| 111 |
-
? "bg-[var(--
|
| 112 |
-
:
|
|
|
|
|
|
|
| 113 |
}`}
|
| 114 |
>
|
| 115 |
<MaterialIcon name="flash_on" className="text-sm" />
|
|
@@ -127,6 +132,12 @@ export function TestBlueprintCard({
|
|
| 127 |
Agentic
|
| 128 |
</button>
|
| 129 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 130 |
|
| 131 |
{mode === "agentic" && (
|
| 132 |
<div className="p-3 rounded-[var(--radius-md)] bg-[var(--badge-blue-bg)] border-2 border-[var(--badge-blue-bg)] text-xs text-[var(--badge-blue-text)]">
|
|
|
|
| 17 |
hasKey: boolean;
|
| 18 |
error: string | null;
|
| 19 |
onGenerate: () => void;
|
| 20 |
+
disableQuick?: boolean;
|
| 21 |
}
|
| 22 |
|
| 23 |
export function TestBlueprintCard({
|
|
|
|
| 35 |
error,
|
| 36 |
onGenerate,
|
| 37 |
onDismissError,
|
| 38 |
+
disableQuick,
|
| 39 |
}: TestBlueprintCardProps & { onDismissError?: () => void }) {
|
| 40 |
const sectionNames = selectedSections
|
| 41 |
.map((id) => SECTIONS.find((s) => s.id === id)?.name)
|
|
|
|
| 107 |
{/* Mode Toggle */}
|
| 108 |
<div className="flex gap-1 p-1 rounded-[var(--radius-lg)] bg-[var(--oat-light)]">
|
| 109 |
<button
|
| 110 |
+
onClick={() => !disableQuick && setMode("quick")}
|
| 111 |
+
disabled={disableQuick}
|
| 112 |
className={`flex-1 py-2.5 px-3 rounded-[var(--radius-md)] text-sm font-semibold transition-all flex items-center justify-center gap-2 min-h-[44px] ${
|
| 113 |
+
disableQuick
|
| 114 |
+
? "bg-[var(--oat-light)] text-[var(--warm-silver)] cursor-not-allowed opacity-50"
|
| 115 |
+
: mode === "quick"
|
| 116 |
+
? "bg-[var(--pure-white)] text-[var(--clay-black)] clay-shadow"
|
| 117 |
+
: "text-[var(--warm-charcoal)] hover:text-[var(--clay-black)]"
|
| 118 |
}`}
|
| 119 |
>
|
| 120 |
<MaterialIcon name="flash_on" className="text-sm" />
|
|
|
|
| 132 |
Agentic
|
| 133 |
</button>
|
| 134 |
</div>
|
| 135 |
+
{disableQuick && (
|
| 136 |
+
<p className="text-xs text-[var(--warm-charcoal)] flex items-center gap-1">
|
| 137 |
+
<MaterialIcon name="info" className="text-xs shrink-0" />
|
| 138 |
+
Reading/Writing membutuhkan Agentic mode (min 20 soal)
|
| 139 |
+
</p>
|
| 140 |
+
)}
|
| 141 |
|
| 142 |
{mode === "agentic" && (
|
| 143 |
<div className="p-3 rounded-[var(--radius-md)] bg-[var(--badge-blue-bg)] border-2 border-[var(--badge-blue-bg)] text-xs text-[var(--badge-blue-text)]">
|
apps/web/src/components/settings/AccountSettings.tsx
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useState } from "react";
|
| 2 |
+
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@labas/ui/components/card";
|
| 3 |
+
import { Input } from "@labas/ui/components/input";
|
| 4 |
+
import { Label } from "@labas/ui/components/label";
|
| 5 |
+
import { Button } from "@labas/ui/components/button";
|
| 6 |
+
import { toast } from "sonner";
|
| 7 |
+
import { authClient } from "@/lib/auth-client";
|
| 8 |
+
|
| 9 |
+
export function AccountSettings() {
|
| 10 |
+
const { data: session } = authClient.useSession();
|
| 11 |
+
const user = session?.user;
|
| 12 |
+
|
| 13 |
+
const [currentPassword, setCurrentPassword] = useState("");
|
| 14 |
+
const [newPassword, setNewPassword] = useState("");
|
| 15 |
+
const [confirmPassword, setConfirmPassword] = useState("");
|
| 16 |
+
const [isChangingPassword, setIsChangingPassword] = useState(false);
|
| 17 |
+
|
| 18 |
+
const [newEmail, setNewEmail] = useState("");
|
| 19 |
+
const [isChangingEmail, setIsChangingEmail] = useState(false);
|
| 20 |
+
|
| 21 |
+
const handleChangePassword = async (e: React.FormEvent) => {
|
| 22 |
+
e.preventDefault();
|
| 23 |
+
if (newPassword.length < 8) {
|
| 24 |
+
toast.error("Password minimal 8 karakter");
|
| 25 |
+
return;
|
| 26 |
+
}
|
| 27 |
+
if (newPassword !== confirmPassword) {
|
| 28 |
+
toast.error("Password baru tidak cocok");
|
| 29 |
+
return;
|
| 30 |
+
}
|
| 31 |
+
setIsChangingPassword(true);
|
| 32 |
+
try {
|
| 33 |
+
await authClient.changePassword({
|
| 34 |
+
currentPassword,
|
| 35 |
+
newPassword,
|
| 36 |
+
revokeOtherSessions: true,
|
| 37 |
+
});
|
| 38 |
+
toast.success("Password berhasil diubah");
|
| 39 |
+
setCurrentPassword("");
|
| 40 |
+
setNewPassword("");
|
| 41 |
+
setConfirmPassword("");
|
| 42 |
+
} catch (err: any) {
|
| 43 |
+
toast.error(err?.message || "Gagal mengubah password");
|
| 44 |
+
} finally {
|
| 45 |
+
setIsChangingPassword(false);
|
| 46 |
+
}
|
| 47 |
+
};
|
| 48 |
+
|
| 49 |
+
const handleChangeEmail = async (e: React.FormEvent) => {
|
| 50 |
+
e.preventDefault();
|
| 51 |
+
if (!newEmail) return;
|
| 52 |
+
setIsChangingEmail(true);
|
| 53 |
+
try {
|
| 54 |
+
await authClient.changeEmail({ newEmail });
|
| 55 |
+
toast.success("Email berhasil diubah. Cek email baru Anda untuk verifikasi.");
|
| 56 |
+
setNewEmail("");
|
| 57 |
+
} catch (err: any) {
|
| 58 |
+
toast.error(err?.message || "Gagal mengubah email");
|
| 59 |
+
} finally {
|
| 60 |
+
setIsChangingEmail(false);
|
| 61 |
+
}
|
| 62 |
+
};
|
| 63 |
+
|
| 64 |
+
return (
|
| 65 |
+
<div className="space-y-6">
|
| 66 |
+
<Card className="clay-shadow bg-[var(--pure-white)] border-2 border-[var(--oat-border)] rounded-[var(--radius-xl)]">
|
| 67 |
+
<CardHeader>
|
| 68 |
+
<CardTitle className="font-headline text-[var(--clay-black)]">Informasi Akun</CardTitle>
|
| 69 |
+
<CardDescription className="text-[var(--warm-charcoal)]">
|
| 70 |
+
Detail akun Anda saat ini
|
| 71 |
+
</CardDescription>
|
| 72 |
+
</CardHeader>
|
| 73 |
+
<CardContent className="space-y-3">
|
| 74 |
+
<div>
|
| 75 |
+
<Label className="text-sm text-[var(--warm-charcoal)]">Nama</Label>
|
| 76 |
+
<p className="font-medium text-[var(--clay-black)]">{user?.name}</p>
|
| 77 |
+
</div>
|
| 78 |
+
<div>
|
| 79 |
+
<Label className="text-sm text-[var(--warm-charcoal)]">Email</Label>
|
| 80 |
+
<p className="font-medium text-[var(--clay-black)]">{user?.email}</p>
|
| 81 |
+
</div>
|
| 82 |
+
</CardContent>
|
| 83 |
+
</Card>
|
| 84 |
+
|
| 85 |
+
<Card className="clay-shadow bg-[var(--pure-white)] border-2 border-[var(--oat-border)] rounded-[var(--radius-xl)]">
|
| 86 |
+
<CardHeader>
|
| 87 |
+
<CardTitle className="font-headline text-[var(--clay-black)]">Ubah Password</CardTitle>
|
| 88 |
+
<CardDescription className="text-[var(--warm-charcoal)]">
|
| 89 |
+
Gunakan password yang kuat dan unik
|
| 90 |
+
</CardDescription>
|
| 91 |
+
</CardHeader>
|
| 92 |
+
<CardContent>
|
| 93 |
+
<form onSubmit={handleChangePassword} className="space-y-4">
|
| 94 |
+
<div className="space-y-2">
|
| 95 |
+
<Label htmlFor="currentPassword">Password Saat Ini</Label>
|
| 96 |
+
<Input
|
| 97 |
+
id="currentPassword"
|
| 98 |
+
type="password"
|
| 99 |
+
value={currentPassword}
|
| 100 |
+
onChange={(e) => setCurrentPassword(e.target.value)}
|
| 101 |
+
placeholder="Masukkan password saat ini"
|
| 102 |
+
/>
|
| 103 |
+
</div>
|
| 104 |
+
<div className="space-y-2">
|
| 105 |
+
<Label htmlFor="newPassword">Password Baru</Label>
|
| 106 |
+
<Input
|
| 107 |
+
id="newPassword"
|
| 108 |
+
type="password"
|
| 109 |
+
value={newPassword}
|
| 110 |
+
onChange={(e) => setNewPassword(e.target.value)}
|
| 111 |
+
placeholder="Minimal 8 karakter"
|
| 112 |
+
/>
|
| 113 |
+
</div>
|
| 114 |
+
<div className="space-y-2">
|
| 115 |
+
<Label htmlFor="confirmPassword">Konfirmasi Password Baru</Label>
|
| 116 |
+
<Input
|
| 117 |
+
id="confirmPassword"
|
| 118 |
+
type="password"
|
| 119 |
+
value={confirmPassword}
|
| 120 |
+
onChange={(e) => setConfirmPassword(e.target.value)}
|
| 121 |
+
placeholder="Ulangi password baru"
|
| 122 |
+
/>
|
| 123 |
+
</div>
|
| 124 |
+
<Button
|
| 125 |
+
type="submit"
|
| 126 |
+
disabled={isChangingPassword || !currentPassword || !newPassword || !confirmPassword}
|
| 127 |
+
>
|
| 128 |
+
{isChangingPassword ? "Menyimpan..." : "Ubah Password"}
|
| 129 |
+
</Button>
|
| 130 |
+
</form>
|
| 131 |
+
</CardContent>
|
| 132 |
+
</Card>
|
| 133 |
+
|
| 134 |
+
<Card className="clay-shadow bg-[var(--pure-white)] border-2 border-[var(--oat-border)] rounded-[var(--radius-xl)]">
|
| 135 |
+
<CardHeader>
|
| 136 |
+
<CardTitle className="font-headline text-[var(--clay-black)]">Ubah Email</CardTitle>
|
| 137 |
+
<CardDescription className="text-[var(--warm-charcoal)]">
|
| 138 |
+
Masukkan alamat email baru Anda
|
| 139 |
+
</CardDescription>
|
| 140 |
+
</CardHeader>
|
| 141 |
+
<CardContent>
|
| 142 |
+
<form onSubmit={handleChangeEmail} className="space-y-4">
|
| 143 |
+
<div className="space-y-2">
|
| 144 |
+
<Label htmlFor="newEmail">Email Baru</Label>
|
| 145 |
+
<Input
|
| 146 |
+
id="newEmail"
|
| 147 |
+
type="email"
|
| 148 |
+
value={newEmail}
|
| 149 |
+
onChange={(e) => setNewEmail(e.target.value)}
|
| 150 |
+
placeholder="nama@email.com"
|
| 151 |
+
/>
|
| 152 |
+
</div>
|
| 153 |
+
<Button type="submit" disabled={isChangingEmail || !newEmail}>
|
| 154 |
+
{isChangingEmail ? "Menyimpan..." : "Ubah Email"}
|
| 155 |
+
</Button>
|
| 156 |
+
</form>
|
| 157 |
+
</CardContent>
|
| 158 |
+
</Card>
|
| 159 |
+
</div>
|
| 160 |
+
);
|
| 161 |
+
}
|
apps/web/src/components/sign-in-form.tsx
CHANGED
|
@@ -3,7 +3,7 @@ import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle }
|
|
| 3 |
import { Input } from "@labas/ui/components/input";
|
| 4 |
import { Label } from "@labas/ui/components/label";
|
| 5 |
import { useForm } from "@tanstack/react-form";
|
| 6 |
-
import { useNavigate } from "@tanstack/react-router";
|
| 7 |
import { toast } from "sonner";
|
| 8 |
import z from "zod";
|
| 9 |
|
|
@@ -30,13 +30,17 @@ export default function SignInForm({ onSwitchToSignUp }: { onSwitchToSignUp: ()
|
|
| 30 |
},
|
| 31 |
{
|
| 32 |
onSuccess: () => {
|
| 33 |
-
navigate({
|
| 34 |
-
to: "/",
|
| 35 |
-
});
|
| 36 |
toast.success("Sign in successful");
|
| 37 |
},
|
| 38 |
onError: (error) => {
|
| 39 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 40 |
},
|
| 41 |
},
|
| 42 |
);
|
|
@@ -95,7 +99,15 @@ export default function SignInForm({ onSwitchToSignUp }: { onSwitchToSignUp: ()
|
|
| 95 |
<form.Field name="password">
|
| 96 |
{(field) => (
|
| 97 |
<div className="space-y-2">
|
| 98 |
-
<
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 99 |
<Input
|
| 100 |
id={field.name}
|
| 101 |
name={field.name}
|
|
|
|
| 3 |
import { Input } from "@labas/ui/components/input";
|
| 4 |
import { Label } from "@labas/ui/components/label";
|
| 5 |
import { useForm } from "@tanstack/react-form";
|
| 6 |
+
import { Link, useNavigate } from "@tanstack/react-router";
|
| 7 |
import { toast } from "sonner";
|
| 8 |
import z from "zod";
|
| 9 |
|
|
|
|
| 30 |
},
|
| 31 |
{
|
| 32 |
onSuccess: () => {
|
| 33 |
+
navigate({ to: "/" });
|
|
|
|
|
|
|
| 34 |
toast.success("Sign in successful");
|
| 35 |
},
|
| 36 |
onError: (error) => {
|
| 37 |
+
const msg = error.error.message || error.error.statusText;
|
| 38 |
+
if (msg?.toLowerCase().includes("verify")) {
|
| 39 |
+
navigate({ to: "/verify-email", search: { email: value.email } });
|
| 40 |
+
toast.error("Silakan verifikasi email Anda terlebih dahulu");
|
| 41 |
+
} else {
|
| 42 |
+
toast.error(msg);
|
| 43 |
+
}
|
| 44 |
},
|
| 45 |
},
|
| 46 |
);
|
|
|
|
| 99 |
<form.Field name="password">
|
| 100 |
{(field) => (
|
| 101 |
<div className="space-y-2">
|
| 102 |
+
<div className="flex items-center justify-between">
|
| 103 |
+
<Label htmlFor={field.name}>Password</Label>
|
| 104 |
+
<Link
|
| 105 |
+
to="/forgot-password"
|
| 106 |
+
className="text-xs text-muted-foreground hover:text-primary"
|
| 107 |
+
>
|
| 108 |
+
Lupa password?
|
| 109 |
+
</Link>
|
| 110 |
+
</div>
|
| 111 |
<Input
|
| 112 |
id={field.name}
|
| 113 |
name={field.name}
|
apps/web/src/components/sign-up-form.tsx
CHANGED
|
@@ -3,11 +3,13 @@ import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle }
|
|
| 3 |
import { Input } from "@labas/ui/components/input";
|
| 4 |
import { Label } from "@labas/ui/components/label";
|
| 5 |
import { useForm } from "@tanstack/react-form";
|
|
|
|
| 6 |
import { useNavigate } from "@tanstack/react-router";
|
| 7 |
import { toast } from "sonner";
|
| 8 |
import z from "zod";
|
| 9 |
|
| 10 |
import { authClient } from "@/lib/auth-client";
|
|
|
|
| 11 |
|
| 12 |
import Loader from "./loader";
|
| 13 |
|
|
@@ -16,6 +18,7 @@ export default function SignUpForm({ onSwitchToSignIn }: { onSwitchToSignIn: ()
|
|
| 16 |
from: "/",
|
| 17 |
});
|
| 18 |
const { isPending } = authClient.useSession();
|
|
|
|
| 19 |
|
| 20 |
const form = useForm({
|
| 21 |
defaultValues: {
|
|
@@ -31,11 +34,15 @@ export default function SignUpForm({ onSwitchToSignIn }: { onSwitchToSignIn: ()
|
|
| 31 |
name: value.name,
|
| 32 |
},
|
| 33 |
{
|
| 34 |
-
onSuccess: () => {
|
|
|
|
|
|
|
|
|
|
| 35 |
navigate({
|
| 36 |
-
to: "/",
|
|
|
|
| 37 |
});
|
| 38 |
-
toast.success("
|
| 39 |
},
|
| 40 |
onError: (error) => {
|
| 41 |
toast.error(error.error.message || error.error.statusText);
|
|
|
|
| 3 |
import { Input } from "@labas/ui/components/input";
|
| 4 |
import { Label } from "@labas/ui/components/label";
|
| 5 |
import { useForm } from "@tanstack/react-form";
|
| 6 |
+
import { useMutation } from "@tanstack/react-query";
|
| 7 |
import { useNavigate } from "@tanstack/react-router";
|
| 8 |
import { toast } from "sonner";
|
| 9 |
import z from "zod";
|
| 10 |
|
| 11 |
import { authClient } from "@/lib/auth-client";
|
| 12 |
+
import { trpc } from "@/utils/trpc";
|
| 13 |
|
| 14 |
import Loader from "./loader";
|
| 15 |
|
|
|
|
| 18 |
from: "/",
|
| 19 |
});
|
| 20 |
const { isPending } = authClient.useSession();
|
| 21 |
+
const sendOtpMutation = useMutation(trpc.verification.sendVerificationOtp.mutationOptions());
|
| 22 |
|
| 23 |
const form = useForm({
|
| 24 |
defaultValues: {
|
|
|
|
| 34 |
name: value.name,
|
| 35 |
},
|
| 36 |
{
|
| 37 |
+
onSuccess: async () => {
|
| 38 |
+
try {
|
| 39 |
+
await sendOtpMutation.mutateAsync({ email: value.email });
|
| 40 |
+
} catch {}
|
| 41 |
navigate({
|
| 42 |
+
to: "/verify-email",
|
| 43 |
+
search: { email: value.email },
|
| 44 |
});
|
| 45 |
+
toast.success("Akun berhasil dibuat! Cek email Anda untuk kode verifikasi.");
|
| 46 |
},
|
| 47 |
onError: (error) => {
|
| 48 |
toast.error(error.error.message || error.error.statusText);
|
apps/web/src/components/test/AttemptTestView.tsx
CHANGED
|
@@ -317,7 +317,7 @@ export function AttemptTestView({
|
|
| 317 |
<Button
|
| 318 |
onClick={() => setShowFinishDialog(true)}
|
| 319 |
disabled={isFinished}
|
| 320 |
-
className="bg-[var(--pomegranate-
|
| 321 |
>
|
| 322 |
Selesai Test
|
| 323 |
</Button>
|
|
@@ -495,7 +495,7 @@ export function AttemptTestView({
|
|
| 495 |
<div className="bg-[var(--warm-cream)] w-full max-w-md rounded-[var(--radius-xl)] border-2 border-[var(--oat-border)] shadow-xl p-6 md:p-8">
|
| 496 |
<div className="flex items-center gap-3 mb-4">
|
| 497 |
<div className="w-10 h-10 rounded-full bg-[var(--pomegranate-400)]/20 flex items-center justify-center">
|
| 498 |
-
<MaterialIcon name="help" className="text-[var(--pomegranate-
|
| 499 |
</div>
|
| 500 |
<h2 className="text-xl font-headline font-bold text-[var(--clay-black)]">
|
| 501 |
Selesaikan Latihan?
|
|
@@ -550,7 +550,7 @@ export function AttemptTestView({
|
|
| 550 |
<div className="bg-[var(--warm-cream)] w-full max-w-md rounded-[var(--radius-xl)] border-2 border-[var(--oat-border)] shadow-xl p-6 md:p-8">
|
| 551 |
<div className="flex items-center gap-3 mb-4">
|
| 552 |
<div className="w-10 h-10 rounded-full bg-[var(--pomegranate-400)]/20 flex items-center justify-center">
|
| 553 |
-
<MaterialIcon name="warning" className="text-[var(--pomegranate-
|
| 554 |
</div>
|
| 555 |
<h2 className="text-xl font-headline font-bold text-[var(--clay-black)]">
|
| 556 |
Keluar dari Latihan?
|
|
@@ -576,7 +576,7 @@ export function AttemptTestView({
|
|
| 576 |
setShowAbandonDialog(false);
|
| 577 |
onAbandon();
|
| 578 |
}}
|
| 579 |
-
className="flex-1 bg-[var(--pomegranate-
|
| 580 |
>
|
| 581 |
Keluar
|
| 582 |
</Button>
|
|
|
|
| 317 |
<Button
|
| 318 |
onClick={() => setShowFinishDialog(true)}
|
| 319 |
disabled={isFinished}
|
| 320 |
+
className="bg-[var(--pomegranate-400)] text-[var(--pure-white)] hover:bg-[var(--pomegranate-600)] px-4 py-2 rounded-xl text-sm font-bold transition-opacity"
|
| 321 |
>
|
| 322 |
Selesai Test
|
| 323 |
</Button>
|
|
|
|
| 495 |
<div className="bg-[var(--warm-cream)] w-full max-w-md rounded-[var(--radius-xl)] border-2 border-[var(--oat-border)] shadow-xl p-6 md:p-8">
|
| 496 |
<div className="flex items-center gap-3 mb-4">
|
| 497 |
<div className="w-10 h-10 rounded-full bg-[var(--pomegranate-400)]/20 flex items-center justify-center">
|
| 498 |
+
<MaterialIcon name="help" className="text-[var(--pomegranate-400)]" />
|
| 499 |
</div>
|
| 500 |
<h2 className="text-xl font-headline font-bold text-[var(--clay-black)]">
|
| 501 |
Selesaikan Latihan?
|
|
|
|
| 550 |
<div className="bg-[var(--warm-cream)] w-full max-w-md rounded-[var(--radius-xl)] border-2 border-[var(--oat-border)] shadow-xl p-6 md:p-8">
|
| 551 |
<div className="flex items-center gap-3 mb-4">
|
| 552 |
<div className="w-10 h-10 rounded-full bg-[var(--pomegranate-400)]/20 flex items-center justify-center">
|
| 553 |
+
<MaterialIcon name="warning" className="text-[var(--pomegranate-400)]" />
|
| 554 |
</div>
|
| 555 |
<h2 className="text-xl font-headline font-bold text-[var(--clay-black)]">
|
| 556 |
Keluar dari Latihan?
|
|
|
|
| 576 |
setShowAbandonDialog(false);
|
| 577 |
onAbandon();
|
| 578 |
}}
|
| 579 |
+
className="flex-1 bg-[var(--pomegranate-400)] text-[var(--pure-white)] hover:bg-[var(--pomegranate-600)] rounded-[var(--radius-lg)]"
|
| 580 |
>
|
| 581 |
Keluar
|
| 582 |
</Button>
|
apps/web/src/hooks/use-generation-jobs.ts
CHANGED
|
@@ -6,6 +6,7 @@ import { authClient } from "@/lib/auth-client";
|
|
| 6 |
|
| 7 |
const STORAGE_KEY = "labas_active_jobs";
|
| 8 |
const RESULTS_KEY = "labas_completed_results";
|
|
|
|
| 9 |
const MAX_PARALLEL = 3;
|
| 10 |
|
| 11 |
export interface ActiveJob {
|
|
@@ -80,6 +81,23 @@ function writeStoredResults(results: CompletedResult[]) {
|
|
| 80 |
}
|
| 81 |
}
|
| 82 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 83 |
function broadcastJobs(ids: string[]) {
|
| 84 |
window.dispatchEvent(
|
| 85 |
new CustomEvent("labas:jobs-change", { detail: { ids } }),
|
|
@@ -135,8 +153,11 @@ export function useGenerationJobs() {
|
|
| 135 |
const resetAll = useCallback(() => {
|
| 136 |
setError(null);
|
| 137 |
setCompletedResults([]);
|
| 138 |
-
setJobIds(
|
| 139 |
-
|
|
|
|
|
|
|
|
|
|
| 140 |
processedJobStates.current = {};
|
| 141 |
sessionStorage.removeItem(RESULTS_KEY);
|
| 142 |
}, [setJobIds]);
|
|
@@ -147,6 +168,11 @@ export function useGenerationJobs() {
|
|
| 147 |
if (saved.length > 0) setJobIdsState(saved);
|
| 148 |
const savedResults = readStoredResults();
|
| 149 |
if (savedResults.length > 0) setCompletedResults(savedResults);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 150 |
}, []);
|
| 151 |
|
| 152 |
// Persist completedResults to sessionStorage
|
|
@@ -173,11 +199,15 @@ export function useGenerationJobs() {
|
|
| 173 |
|
| 174 |
useEffect(() => {
|
| 175 |
if (!myJobsQuery.data) return;
|
|
|
|
|
|
|
|
|
|
| 176 |
const activeIds = myJobsQuery.data
|
| 177 |
.filter(
|
| 178 |
(j) =>
|
| 179 |
ACTIVE_STATUSES.has(j.status) &&
|
| 180 |
-
!removedJobIdsRef.current.has(j.id)
|
|
|
|
| 181 |
)
|
| 182 |
.map((j) => j.id);
|
| 183 |
if (activeIds.length === 0) return;
|
|
|
|
| 6 |
|
| 7 |
const STORAGE_KEY = "labas_active_jobs";
|
| 8 |
const RESULTS_KEY = "labas_completed_results";
|
| 9 |
+
const CLEARED_JOBS_KEY = "labas_cleared_jobs";
|
| 10 |
const MAX_PARALLEL = 3;
|
| 11 |
|
| 12 |
export interface ActiveJob {
|
|
|
|
| 81 |
}
|
| 82 |
}
|
| 83 |
|
| 84 |
+
function readClearedJobIds(): string[] {
|
| 85 |
+
try {
|
| 86 |
+
const raw = sessionStorage.getItem(CLEARED_JOBS_KEY);
|
| 87 |
+
return raw ? (JSON.parse(raw) as string[]) : [];
|
| 88 |
+
} catch {
|
| 89 |
+
return [];
|
| 90 |
+
}
|
| 91 |
+
}
|
| 92 |
+
|
| 93 |
+
function writeClearedJobIds(ids: string[]) {
|
| 94 |
+
if (ids.length === 0) {
|
| 95 |
+
sessionStorage.removeItem(CLEARED_JOBS_KEY);
|
| 96 |
+
} else {
|
| 97 |
+
sessionStorage.setItem(CLEARED_JOBS_KEY, JSON.stringify(ids));
|
| 98 |
+
}
|
| 99 |
+
}
|
| 100 |
+
|
| 101 |
function broadcastJobs(ids: string[]) {
|
| 102 |
window.dispatchEvent(
|
| 103 |
new CustomEvent("labas:jobs-change", { detail: { ids } }),
|
|
|
|
| 153 |
const resetAll = useCallback(() => {
|
| 154 |
setError(null);
|
| 155 |
setCompletedResults([]);
|
| 156 |
+
setJobIds((prev) => {
|
| 157 |
+
writeClearedJobIds(prev);
|
| 158 |
+
removedJobIdsRef.current.clear();
|
| 159 |
+
return [];
|
| 160 |
+
});
|
| 161 |
processedJobStates.current = {};
|
| 162 |
sessionStorage.removeItem(RESULTS_KEY);
|
| 163 |
}, [setJobIds]);
|
|
|
|
| 168 |
if (saved.length > 0) setJobIdsState(saved);
|
| 169 |
const savedResults = readStoredResults();
|
| 170 |
if (savedResults.length > 0) setCompletedResults(savedResults);
|
| 171 |
+
|
| 172 |
+
const clearedIds = readClearedJobIds();
|
| 173 |
+
for (const id of clearedIds) {
|
| 174 |
+
removedJobIdsRef.current.add(id);
|
| 175 |
+
}
|
| 176 |
}, []);
|
| 177 |
|
| 178 |
// Persist completedResults to sessionStorage
|
|
|
|
| 199 |
|
| 200 |
useEffect(() => {
|
| 201 |
if (!myJobsQuery.data) return;
|
| 202 |
+
|
| 203 |
+
const clearedIds = readClearedJobIds();
|
| 204 |
+
|
| 205 |
const activeIds = myJobsQuery.data
|
| 206 |
.filter(
|
| 207 |
(j) =>
|
| 208 |
ACTIVE_STATUSES.has(j.status) &&
|
| 209 |
+
!removedJobIdsRef.current.has(j.id) &&
|
| 210 |
+
!clearedIds.includes(j.id),
|
| 211 |
)
|
| 212 |
.map((j) => j.id);
|
| 213 |
if (activeIds.length === 0) return;
|
apps/web/src/routeTree.gen.ts
CHANGED
|
@@ -9,6 +9,7 @@
|
|
| 9 |
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
|
| 10 |
|
| 11 |
import { Route as rootRouteImport } from './routes/__root'
|
|
|
|
| 12 |
import { Route as SetupAvatarRouteImport } from './routes/setup-avatar'
|
| 13 |
import { Route as SettingsRouteImport } from './routes/settings'
|
| 14 |
import { Route as PackagesRouteImport } from './routes/packages'
|
|
@@ -19,6 +20,7 @@ import { Route as LandingRouteImport } from './routes/landing'
|
|
| 19 |
import { Route as JobsRouteImport } from './routes/jobs'
|
| 20 |
import { Route as HistoryRouteImport } from './routes/history'
|
| 21 |
import { Route as GenerateRouteImport } from './routes/generate'
|
|
|
|
| 22 |
import { Route as BankRouteImport } from './routes/bank'
|
| 23 |
import { Route as AnalyticsRouteImport } from './routes/analytics'
|
| 24 |
import { Route as IndexRouteImport } from './routes/index'
|
|
@@ -29,6 +31,11 @@ import { Route as PackageIdIndexRouteImport } from './routes/package.$id.index'
|
|
| 29 |
import { Route as PackageIdTakeRouteImport } from './routes/package.$id.take'
|
| 30 |
import { Route as PackageIdAttemptAttemptIdRouteImport } from './routes/package.$id.attempt.$attemptId'
|
| 31 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 32 |
const SetupAvatarRoute = SetupAvatarRouteImport.update({
|
| 33 |
id: '/setup-avatar',
|
| 34 |
path: '/setup-avatar',
|
|
@@ -79,6 +86,11 @@ const GenerateRoute = GenerateRouteImport.update({
|
|
| 79 |
path: '/generate',
|
| 80 |
getParentRoute: () => rootRouteImport,
|
| 81 |
} as any)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 82 |
const BankRoute = BankRouteImport.update({
|
| 83 |
id: '/bank',
|
| 84 |
path: '/bank',
|
|
@@ -130,6 +142,7 @@ export interface FileRoutesByFullPath {
|
|
| 130 |
'/': typeof IndexRoute
|
| 131 |
'/analytics': typeof AnalyticsRoute
|
| 132 |
'/bank': typeof BankRoute
|
|
|
|
| 133 |
'/generate': typeof GenerateRoute
|
| 134 |
'/history': typeof HistoryRoute
|
| 135 |
'/jobs': typeof JobsRoute
|
|
@@ -140,6 +153,7 @@ export interface FileRoutesByFullPath {
|
|
| 140 |
'/packages': typeof PackagesRoute
|
| 141 |
'/settings': typeof SettingsRoute
|
| 142 |
'/setup-avatar': typeof SetupAvatarRoute
|
|
|
|
| 143 |
'/attempt/$id': typeof AttemptIdRoute
|
| 144 |
'/package/$id': typeof PackageIdRouteWithChildren
|
| 145 |
'/profile/$userId': typeof ProfileUserIdRoute
|
|
@@ -151,6 +165,7 @@ export interface FileRoutesByTo {
|
|
| 151 |
'/': typeof IndexRoute
|
| 152 |
'/analytics': typeof AnalyticsRoute
|
| 153 |
'/bank': typeof BankRoute
|
|
|
|
| 154 |
'/generate': typeof GenerateRoute
|
| 155 |
'/history': typeof HistoryRoute
|
| 156 |
'/jobs': typeof JobsRoute
|
|
@@ -161,6 +176,7 @@ export interface FileRoutesByTo {
|
|
| 161 |
'/packages': typeof PackagesRoute
|
| 162 |
'/settings': typeof SettingsRoute
|
| 163 |
'/setup-avatar': typeof SetupAvatarRoute
|
|
|
|
| 164 |
'/attempt/$id': typeof AttemptIdRoute
|
| 165 |
'/profile/$userId': typeof ProfileUserIdRoute
|
| 166 |
'/package/$id/take': typeof PackageIdTakeRoute
|
|
@@ -172,6 +188,7 @@ export interface FileRoutesById {
|
|
| 172 |
'/': typeof IndexRoute
|
| 173 |
'/analytics': typeof AnalyticsRoute
|
| 174 |
'/bank': typeof BankRoute
|
|
|
|
| 175 |
'/generate': typeof GenerateRoute
|
| 176 |
'/history': typeof HistoryRoute
|
| 177 |
'/jobs': typeof JobsRoute
|
|
@@ -182,6 +199,7 @@ export interface FileRoutesById {
|
|
| 182 |
'/packages': typeof PackagesRoute
|
| 183 |
'/settings': typeof SettingsRoute
|
| 184 |
'/setup-avatar': typeof SetupAvatarRoute
|
|
|
|
| 185 |
'/attempt/$id': typeof AttemptIdRoute
|
| 186 |
'/package/$id': typeof PackageIdRouteWithChildren
|
| 187 |
'/profile/$userId': typeof ProfileUserIdRoute
|
|
@@ -195,6 +213,7 @@ export interface FileRouteTypes {
|
|
| 195 |
| '/'
|
| 196 |
| '/analytics'
|
| 197 |
| '/bank'
|
|
|
|
| 198 |
| '/generate'
|
| 199 |
| '/history'
|
| 200 |
| '/jobs'
|
|
@@ -205,6 +224,7 @@ export interface FileRouteTypes {
|
|
| 205 |
| '/packages'
|
| 206 |
| '/settings'
|
| 207 |
| '/setup-avatar'
|
|
|
|
| 208 |
| '/attempt/$id'
|
| 209 |
| '/package/$id'
|
| 210 |
| '/profile/$userId'
|
|
@@ -216,6 +236,7 @@ export interface FileRouteTypes {
|
|
| 216 |
| '/'
|
| 217 |
| '/analytics'
|
| 218 |
| '/bank'
|
|
|
|
| 219 |
| '/generate'
|
| 220 |
| '/history'
|
| 221 |
| '/jobs'
|
|
@@ -226,6 +247,7 @@ export interface FileRouteTypes {
|
|
| 226 |
| '/packages'
|
| 227 |
| '/settings'
|
| 228 |
| '/setup-avatar'
|
|
|
|
| 229 |
| '/attempt/$id'
|
| 230 |
| '/profile/$userId'
|
| 231 |
| '/package/$id/take'
|
|
@@ -236,6 +258,7 @@ export interface FileRouteTypes {
|
|
| 236 |
| '/'
|
| 237 |
| '/analytics'
|
| 238 |
| '/bank'
|
|
|
|
| 239 |
| '/generate'
|
| 240 |
| '/history'
|
| 241 |
| '/jobs'
|
|
@@ -246,6 +269,7 @@ export interface FileRouteTypes {
|
|
| 246 |
| '/packages'
|
| 247 |
| '/settings'
|
| 248 |
| '/setup-avatar'
|
|
|
|
| 249 |
| '/attempt/$id'
|
| 250 |
| '/package/$id'
|
| 251 |
| '/profile/$userId'
|
|
@@ -258,6 +282,7 @@ export interface RootRouteChildren {
|
|
| 258 |
IndexRoute: typeof IndexRoute
|
| 259 |
AnalyticsRoute: typeof AnalyticsRoute
|
| 260 |
BankRoute: typeof BankRoute
|
|
|
|
| 261 |
GenerateRoute: typeof GenerateRoute
|
| 262 |
HistoryRoute: typeof HistoryRoute
|
| 263 |
JobsRoute: typeof JobsRoute
|
|
@@ -268,6 +293,7 @@ export interface RootRouteChildren {
|
|
| 268 |
PackagesRoute: typeof PackagesRoute
|
| 269 |
SettingsRoute: typeof SettingsRoute
|
| 270 |
SetupAvatarRoute: typeof SetupAvatarRoute
|
|
|
|
| 271 |
AttemptIdRoute: typeof AttemptIdRoute
|
| 272 |
PackageIdRoute: typeof PackageIdRouteWithChildren
|
| 273 |
ProfileUserIdRoute: typeof ProfileUserIdRoute
|
|
@@ -275,6 +301,13 @@ export interface RootRouteChildren {
|
|
| 275 |
|
| 276 |
declare module '@tanstack/react-router' {
|
| 277 |
interface FileRoutesByPath {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 278 |
'/setup-avatar': {
|
| 279 |
id: '/setup-avatar'
|
| 280 |
path: '/setup-avatar'
|
|
@@ -345,6 +378,13 @@ declare module '@tanstack/react-router' {
|
|
| 345 |
preLoaderRoute: typeof GenerateRouteImport
|
| 346 |
parentRoute: typeof rootRouteImport
|
| 347 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 348 |
'/bank': {
|
| 349 |
id: '/bank'
|
| 350 |
path: '/bank'
|
|
@@ -431,6 +471,7 @@ const rootRouteChildren: RootRouteChildren = {
|
|
| 431 |
IndexRoute: IndexRoute,
|
| 432 |
AnalyticsRoute: AnalyticsRoute,
|
| 433 |
BankRoute: BankRoute,
|
|
|
|
| 434 |
GenerateRoute: GenerateRoute,
|
| 435 |
HistoryRoute: HistoryRoute,
|
| 436 |
JobsRoute: JobsRoute,
|
|
@@ -441,6 +482,7 @@ const rootRouteChildren: RootRouteChildren = {
|
|
| 441 |
PackagesRoute: PackagesRoute,
|
| 442 |
SettingsRoute: SettingsRoute,
|
| 443 |
SetupAvatarRoute: SetupAvatarRoute,
|
|
|
|
| 444 |
AttemptIdRoute: AttemptIdRoute,
|
| 445 |
PackageIdRoute: PackageIdRouteWithChildren,
|
| 446 |
ProfileUserIdRoute: ProfileUserIdRoute,
|
|
|
|
| 9 |
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
|
| 10 |
|
| 11 |
import { Route as rootRouteImport } from './routes/__root'
|
| 12 |
+
import { Route as VerifyEmailRouteImport } from './routes/verify-email'
|
| 13 |
import { Route as SetupAvatarRouteImport } from './routes/setup-avatar'
|
| 14 |
import { Route as SettingsRouteImport } from './routes/settings'
|
| 15 |
import { Route as PackagesRouteImport } from './routes/packages'
|
|
|
|
| 20 |
import { Route as JobsRouteImport } from './routes/jobs'
|
| 21 |
import { Route as HistoryRouteImport } from './routes/history'
|
| 22 |
import { Route as GenerateRouteImport } from './routes/generate'
|
| 23 |
+
import { Route as ForgotPasswordRouteImport } from './routes/forgot-password'
|
| 24 |
import { Route as BankRouteImport } from './routes/bank'
|
| 25 |
import { Route as AnalyticsRouteImport } from './routes/analytics'
|
| 26 |
import { Route as IndexRouteImport } from './routes/index'
|
|
|
|
| 31 |
import { Route as PackageIdTakeRouteImport } from './routes/package.$id.take'
|
| 32 |
import { Route as PackageIdAttemptAttemptIdRouteImport } from './routes/package.$id.attempt.$attemptId'
|
| 33 |
|
| 34 |
+
const VerifyEmailRoute = VerifyEmailRouteImport.update({
|
| 35 |
+
id: '/verify-email',
|
| 36 |
+
path: '/verify-email',
|
| 37 |
+
getParentRoute: () => rootRouteImport,
|
| 38 |
+
} as any)
|
| 39 |
const SetupAvatarRoute = SetupAvatarRouteImport.update({
|
| 40 |
id: '/setup-avatar',
|
| 41 |
path: '/setup-avatar',
|
|
|
|
| 86 |
path: '/generate',
|
| 87 |
getParentRoute: () => rootRouteImport,
|
| 88 |
} as any)
|
| 89 |
+
const ForgotPasswordRoute = ForgotPasswordRouteImport.update({
|
| 90 |
+
id: '/forgot-password',
|
| 91 |
+
path: '/forgot-password',
|
| 92 |
+
getParentRoute: () => rootRouteImport,
|
| 93 |
+
} as any)
|
| 94 |
const BankRoute = BankRouteImport.update({
|
| 95 |
id: '/bank',
|
| 96 |
path: '/bank',
|
|
|
|
| 142 |
'/': typeof IndexRoute
|
| 143 |
'/analytics': typeof AnalyticsRoute
|
| 144 |
'/bank': typeof BankRoute
|
| 145 |
+
'/forgot-password': typeof ForgotPasswordRoute
|
| 146 |
'/generate': typeof GenerateRoute
|
| 147 |
'/history': typeof HistoryRoute
|
| 148 |
'/jobs': typeof JobsRoute
|
|
|
|
| 153 |
'/packages': typeof PackagesRoute
|
| 154 |
'/settings': typeof SettingsRoute
|
| 155 |
'/setup-avatar': typeof SetupAvatarRoute
|
| 156 |
+
'/verify-email': typeof VerifyEmailRoute
|
| 157 |
'/attempt/$id': typeof AttemptIdRoute
|
| 158 |
'/package/$id': typeof PackageIdRouteWithChildren
|
| 159 |
'/profile/$userId': typeof ProfileUserIdRoute
|
|
|
|
| 165 |
'/': typeof IndexRoute
|
| 166 |
'/analytics': typeof AnalyticsRoute
|
| 167 |
'/bank': typeof BankRoute
|
| 168 |
+
'/forgot-password': typeof ForgotPasswordRoute
|
| 169 |
'/generate': typeof GenerateRoute
|
| 170 |
'/history': typeof HistoryRoute
|
| 171 |
'/jobs': typeof JobsRoute
|
|
|
|
| 176 |
'/packages': typeof PackagesRoute
|
| 177 |
'/settings': typeof SettingsRoute
|
| 178 |
'/setup-avatar': typeof SetupAvatarRoute
|
| 179 |
+
'/verify-email': typeof VerifyEmailRoute
|
| 180 |
'/attempt/$id': typeof AttemptIdRoute
|
| 181 |
'/profile/$userId': typeof ProfileUserIdRoute
|
| 182 |
'/package/$id/take': typeof PackageIdTakeRoute
|
|
|
|
| 188 |
'/': typeof IndexRoute
|
| 189 |
'/analytics': typeof AnalyticsRoute
|
| 190 |
'/bank': typeof BankRoute
|
| 191 |
+
'/forgot-password': typeof ForgotPasswordRoute
|
| 192 |
'/generate': typeof GenerateRoute
|
| 193 |
'/history': typeof HistoryRoute
|
| 194 |
'/jobs': typeof JobsRoute
|
|
|
|
| 199 |
'/packages': typeof PackagesRoute
|
| 200 |
'/settings': typeof SettingsRoute
|
| 201 |
'/setup-avatar': typeof SetupAvatarRoute
|
| 202 |
+
'/verify-email': typeof VerifyEmailRoute
|
| 203 |
'/attempt/$id': typeof AttemptIdRoute
|
| 204 |
'/package/$id': typeof PackageIdRouteWithChildren
|
| 205 |
'/profile/$userId': typeof ProfileUserIdRoute
|
|
|
|
| 213 |
| '/'
|
| 214 |
| '/analytics'
|
| 215 |
| '/bank'
|
| 216 |
+
| '/forgot-password'
|
| 217 |
| '/generate'
|
| 218 |
| '/history'
|
| 219 |
| '/jobs'
|
|
|
|
| 224 |
| '/packages'
|
| 225 |
| '/settings'
|
| 226 |
| '/setup-avatar'
|
| 227 |
+
| '/verify-email'
|
| 228 |
| '/attempt/$id'
|
| 229 |
| '/package/$id'
|
| 230 |
| '/profile/$userId'
|
|
|
|
| 236 |
| '/'
|
| 237 |
| '/analytics'
|
| 238 |
| '/bank'
|
| 239 |
+
| '/forgot-password'
|
| 240 |
| '/generate'
|
| 241 |
| '/history'
|
| 242 |
| '/jobs'
|
|
|
|
| 247 |
| '/packages'
|
| 248 |
| '/settings'
|
| 249 |
| '/setup-avatar'
|
| 250 |
+
| '/verify-email'
|
| 251 |
| '/attempt/$id'
|
| 252 |
| '/profile/$userId'
|
| 253 |
| '/package/$id/take'
|
|
|
|
| 258 |
| '/'
|
| 259 |
| '/analytics'
|
| 260 |
| '/bank'
|
| 261 |
+
| '/forgot-password'
|
| 262 |
| '/generate'
|
| 263 |
| '/history'
|
| 264 |
| '/jobs'
|
|
|
|
| 269 |
| '/packages'
|
| 270 |
| '/settings'
|
| 271 |
| '/setup-avatar'
|
| 272 |
+
| '/verify-email'
|
| 273 |
| '/attempt/$id'
|
| 274 |
| '/package/$id'
|
| 275 |
| '/profile/$userId'
|
|
|
|
| 282 |
IndexRoute: typeof IndexRoute
|
| 283 |
AnalyticsRoute: typeof AnalyticsRoute
|
| 284 |
BankRoute: typeof BankRoute
|
| 285 |
+
ForgotPasswordRoute: typeof ForgotPasswordRoute
|
| 286 |
GenerateRoute: typeof GenerateRoute
|
| 287 |
HistoryRoute: typeof HistoryRoute
|
| 288 |
JobsRoute: typeof JobsRoute
|
|
|
|
| 293 |
PackagesRoute: typeof PackagesRoute
|
| 294 |
SettingsRoute: typeof SettingsRoute
|
| 295 |
SetupAvatarRoute: typeof SetupAvatarRoute
|
| 296 |
+
VerifyEmailRoute: typeof VerifyEmailRoute
|
| 297 |
AttemptIdRoute: typeof AttemptIdRoute
|
| 298 |
PackageIdRoute: typeof PackageIdRouteWithChildren
|
| 299 |
ProfileUserIdRoute: typeof ProfileUserIdRoute
|
|
|
|
| 301 |
|
| 302 |
declare module '@tanstack/react-router' {
|
| 303 |
interface FileRoutesByPath {
|
| 304 |
+
'/verify-email': {
|
| 305 |
+
id: '/verify-email'
|
| 306 |
+
path: '/verify-email'
|
| 307 |
+
fullPath: '/verify-email'
|
| 308 |
+
preLoaderRoute: typeof VerifyEmailRouteImport
|
| 309 |
+
parentRoute: typeof rootRouteImport
|
| 310 |
+
}
|
| 311 |
'/setup-avatar': {
|
| 312 |
id: '/setup-avatar'
|
| 313 |
path: '/setup-avatar'
|
|
|
|
| 378 |
preLoaderRoute: typeof GenerateRouteImport
|
| 379 |
parentRoute: typeof rootRouteImport
|
| 380 |
}
|
| 381 |
+
'/forgot-password': {
|
| 382 |
+
id: '/forgot-password'
|
| 383 |
+
path: '/forgot-password'
|
| 384 |
+
fullPath: '/forgot-password'
|
| 385 |
+
preLoaderRoute: typeof ForgotPasswordRouteImport
|
| 386 |
+
parentRoute: typeof rootRouteImport
|
| 387 |
+
}
|
| 388 |
'/bank': {
|
| 389 |
id: '/bank'
|
| 390 |
path: '/bank'
|
|
|
|
| 471 |
IndexRoute: IndexRoute,
|
| 472 |
AnalyticsRoute: AnalyticsRoute,
|
| 473 |
BankRoute: BankRoute,
|
| 474 |
+
ForgotPasswordRoute: ForgotPasswordRoute,
|
| 475 |
GenerateRoute: GenerateRoute,
|
| 476 |
HistoryRoute: HistoryRoute,
|
| 477 |
JobsRoute: JobsRoute,
|
|
|
|
| 482 |
PackagesRoute: PackagesRoute,
|
| 483 |
SettingsRoute: SettingsRoute,
|
| 484 |
SetupAvatarRoute: SetupAvatarRoute,
|
| 485 |
+
VerifyEmailRoute: VerifyEmailRoute,
|
| 486 |
AttemptIdRoute: AttemptIdRoute,
|
| 487 |
PackageIdRoute: PackageIdRouteWithChildren,
|
| 488 |
ProfileUserIdRoute: ProfileUserIdRoute,
|
apps/web/src/routes/__root.tsx
CHANGED
|
@@ -40,7 +40,9 @@ function RootComponent() {
|
|
| 40 |
m.routeId === "/package/$id/take" ||
|
| 41 |
m.routeId === "/package/$id/attempt/$attemptId" ||
|
| 42 |
m.routeId === "/login" ||
|
| 43 |
-
m.routeId === "/setup-avatar"
|
|
|
|
|
|
|
| 44 |
);
|
| 45 |
const isLanding = matches.some((m) => m.routeId === "/landing");
|
| 46 |
|
|
|
|
| 40 |
m.routeId === "/package/$id/take" ||
|
| 41 |
m.routeId === "/package/$id/attempt/$attemptId" ||
|
| 42 |
m.routeId === "/login" ||
|
| 43 |
+
m.routeId === "/setup-avatar" ||
|
| 44 |
+
m.routeId === "/verify-email" ||
|
| 45 |
+
m.routeId === "/forgot-password",
|
| 46 |
);
|
| 47 |
const isLanding = matches.some((m) => m.routeId === "/landing");
|
| 48 |
|
apps/web/src/routes/attempt.$id.tsx
CHANGED
|
@@ -1,13 +1,17 @@
|
|
| 1 |
-
import { useState } from "react";
|
| 2 |
import { useQuery, useMutation } 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 { Button } from "@labas/ui/components/button";
|
| 7 |
import { Card, CardContent } from "@labas/ui/components/card";
|
| 8 |
-
import { Input } from "@labas/ui/components/input";
|
| 9 |
import { MaterialIcon } from "@/components/ui/MaterialIcon";
|
| 10 |
import { formatTime } from "@/lib/time";
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 11 |
|
| 12 |
export const Route = createFileRoute("/attempt/$id")({
|
| 13 |
component: AttemptResultComponent,
|
|
@@ -47,6 +51,75 @@ function AttemptResultComponent() {
|
|
| 47 |
? Math.round((new Date(attempt.finishedAt).getTime() - new Date(attempt.startedAt).getTime()) / 1000)
|
| 48 |
: 0;
|
| 49 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 50 |
if (attemptQuery.isLoading) {
|
| 51 |
return (
|
| 52 |
<div className="min-h-screen pt-8 pb-32 px-6 md:px-12 lg:px-16 max-w-4xl mx-auto bg-[var(--warm-cream)]">
|
|
@@ -124,7 +197,7 @@ function AttemptResultComponent() {
|
|
| 124 |
{attempt.totalScore ?? 0} benar
|
| 125 |
</span>
|
| 126 |
<span className="flex items-center gap-1">
|
| 127 |
-
<MaterialIcon name="cancel" className="text-sm text-[var(--pomegranate-
|
| 128 |
{(attempt.maxScore ?? 0) - (attempt.totalScore ?? 0)} salah
|
| 129 |
</span>
|
| 130 |
</div>
|
|
@@ -175,7 +248,7 @@ function AttemptResultComponent() {
|
|
| 175 |
<p className="text-sm text-[var(--warm-charcoal)]">
|
| 176 |
{sec.score ?? 0}/{sec.maxScore ?? 0} benar
|
| 177 |
</p>
|
| 178 |
-
{sec.timeSpentSec !=
|
| 179 |
<p className="text-xs text-[var(--warm-silver)] mt-1 flex items-center gap-1">
|
| 180 |
<MaterialIcon name="timer" className="text-xs" />
|
| 181 |
{formatTime(sec.timeSpentSec)}
|
|
@@ -193,21 +266,71 @@ function AttemptResultComponent() {
|
|
| 193 |
</div>
|
| 194 |
</div>
|
| 195 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 196 |
{/* Question Review */}
|
| 197 |
<div className="mb-8">
|
| 198 |
-
<h2 className="text-xl font-headline font-bold text-[var(--clay-black)] mb-4">
|
| 199 |
-
|
| 200 |
-
{
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 211 |
)}
|
| 212 |
</div>
|
| 213 |
</div>
|
|
@@ -233,243 +356,3 @@ function AttemptResultComponent() {
|
|
| 233 |
</div>
|
| 234 |
);
|
| 235 |
}
|
| 236 |
-
|
| 237 |
-
function QuestionReviewCard({
|
| 238 |
-
q,
|
| 239 |
-
secIdx,
|
| 240 |
-
qIdx,
|
| 241 |
-
ans,
|
| 242 |
-
userId,
|
| 243 |
-
}: {
|
| 244 |
-
q: any;
|
| 245 |
-
secIdx: number;
|
| 246 |
-
qIdx: number;
|
| 247 |
-
ans: any;
|
| 248 |
-
userId?: string;
|
| 249 |
-
}) {
|
| 250 |
-
const isCorrect = ans?.isCorrect;
|
| 251 |
-
const userAnswer = ans?.userAnswer ?? "Tidak dijawab";
|
| 252 |
-
const isOwner = q.creatorUserId === userId;
|
| 253 |
-
|
| 254 |
-
const [isEditing, setIsEditing] = useState(false);
|
| 255 |
-
const [editPassage, setEditPassage] = useState(q.passageText ?? "");
|
| 256 |
-
const [editCorrectAnswer, setEditCorrectAnswer] = useState(q.correctAnswer ?? "");
|
| 257 |
-
const [editExplanation, setEditExplanation] = useState(q.explanation ?? "");
|
| 258 |
-
|
| 259 |
-
const feedbackQuery = useQuery(
|
| 260 |
-
trpc.feedback.getQuestionFeedback.queryOptions(
|
| 261 |
-
{ questionId: q.id },
|
| 262 |
-
{ enabled: !!q.id },
|
| 263 |
-
),
|
| 264 |
-
);
|
| 265 |
-
|
| 266 |
-
const voteMutation = useMutation({
|
| 267 |
-
...trpc.feedback.voteQuestion.mutationOptions(),
|
| 268 |
-
onSuccess: () => feedbackQuery.refetch(),
|
| 269 |
-
});
|
| 270 |
-
|
| 271 |
-
const updateQuestionMutation = useMutation({
|
| 272 |
-
...trpc.question.update.mutationOptions(),
|
| 273 |
-
onSuccess: () => {
|
| 274 |
-
setIsEditing(false);
|
| 275 |
-
// Ideally refetch attempt data here
|
| 276 |
-
window.location.reload();
|
| 277 |
-
},
|
| 278 |
-
});
|
| 279 |
-
|
| 280 |
-
const handleSaveEdit = () => {
|
| 281 |
-
updateQuestionMutation.mutate({
|
| 282 |
-
id: q.id,
|
| 283 |
-
passageText: editPassage,
|
| 284 |
-
correctAnswer: editCorrectAnswer,
|
| 285 |
-
explanation: editExplanation,
|
| 286 |
-
});
|
| 287 |
-
};
|
| 288 |
-
|
| 289 |
-
return (
|
| 290 |
-
<Card
|
| 291 |
-
className={`clay-shadow bg-[var(--pure-white)] border-2 rounded-[var(--radius-xl)] ${
|
| 292 |
-
isCorrect === true
|
| 293 |
-
? "border-[var(--matcha-400)]"
|
| 294 |
-
: isCorrect === false
|
| 295 |
-
? "border-[var(--pomegranate-400)]"
|
| 296 |
-
: "border-[var(--oat-border)]"
|
| 297 |
-
}`}
|
| 298 |
-
>
|
| 299 |
-
<CardContent className="p-5">
|
| 300 |
-
<div className="flex items-start gap-3 mb-3">
|
| 301 |
-
<span
|
| 302 |
-
className={`w-8 h-8 rounded-full text-xs flex items-center justify-center font-bold shrink-0 ${
|
| 303 |
-
isCorrect === true
|
| 304 |
-
? "bg-[var(--matcha-600)] text-[var(--pure-white)]"
|
| 305 |
-
: isCorrect === false
|
| 306 |
-
? "bg-[var(--pomegranate-500)] text-[var(--pure-white)]"
|
| 307 |
-
: "bg-[var(--oat-light)] text-[var(--warm-charcoal)]"
|
| 308 |
-
}`}
|
| 309 |
-
>
|
| 310 |
-
{secIdx + 1}.{qIdx + 1}
|
| 311 |
-
</span>
|
| 312 |
-
<div className="flex-1">
|
| 313 |
-
<p className="text-[var(--clay-black)] font-medium">{q.questionText}</p>
|
| 314 |
-
<div className="flex gap-2 mt-1 flex-wrap">
|
| 315 |
-
<span className="text-xs px-2 py-0.5 rounded bg-[var(--oat-light)] text-[var(--warm-charcoal)]">
|
| 316 |
-
{q.format.replace(/_/g, " ")}
|
| 317 |
-
</span>
|
| 318 |
-
</div>
|
| 319 |
-
</div>
|
| 320 |
-
{isOwner && !isEditing && (
|
| 321 |
-
<button
|
| 322 |
-
onClick={() => setIsEditing(true)}
|
| 323 |
-
className="p-1.5 rounded-md hover:bg-[var(--oat-light)] transition-colors text-[var(--warm-charcoal)]"
|
| 324 |
-
title="Koreksi soal"
|
| 325 |
-
>
|
| 326 |
-
<MaterialIcon name="edit" className="text-sm" />
|
| 327 |
-
</button>
|
| 328 |
-
)}
|
| 329 |
-
{isEditing && (
|
| 330 |
-
<button
|
| 331 |
-
onClick={() => setIsEditing(false)}
|
| 332 |
-
className="p-1.5 rounded-md hover:bg-[var(--oat-light)] transition-colors text-[var(--warm-charcoal)]"
|
| 333 |
-
title="Batal"
|
| 334 |
-
>
|
| 335 |
-
<MaterialIcon name="close" className="text-sm" />
|
| 336 |
-
</button>
|
| 337 |
-
)}
|
| 338 |
-
</div>
|
| 339 |
-
|
| 340 |
-
<div className="pl-11 space-y-3">
|
| 341 |
-
{/* Passage Text */}
|
| 342 |
-
{q.passageText && (
|
| 343 |
-
<div className="bg-[var(--oat-light)] rounded-[var(--radius-lg)] p-3 text-sm text-[var(--warm-charcoal)] whitespace-pre-wrap leading-relaxed">
|
| 344 |
-
<span className="font-semibold text-[var(--clay-black)] block mb-1">Teks Bacaan:</span>
|
| 345 |
-
{q.passageText}
|
| 346 |
-
</div>
|
| 347 |
-
)}
|
| 348 |
-
|
| 349 |
-
{/* Owner Inline Edit Form */}
|
| 350 |
-
{isEditing && isOwner && (
|
| 351 |
-
<div className="space-y-3 bg-[var(--badge-blue-bg)] rounded-[var(--radius-lg)] p-4 border-2 border-[var(--badge-blue-bg)]">
|
| 352 |
-
<p className="text-sm font-semibold text-[var(--badge-blue-text)] flex items-center gap-2">
|
| 353 |
-
<MaterialIcon name="edit_note" className="text-sm" />
|
| 354 |
-
Koreksi Soal
|
| 355 |
-
</p>
|
| 356 |
-
<div>
|
| 357 |
-
<label className="text-xs font-medium text-[var(--warm-charcoal)] mb-1 block">Teks Bacaan</label>
|
| 358 |
-
<textarea
|
| 359 |
-
value={editPassage}
|
| 360 |
-
onChange={(e) => setEditPassage(e.target.value)}
|
| 361 |
-
className="w-full min-h-[80px] p-2 rounded-[var(--radius-md)] border-2 border-[var(--oat-border)] bg-[var(--pure-white)] text-sm text-[var(--clay-black)] resize-y"
|
| 362 |
-
/>
|
| 363 |
-
</div>
|
| 364 |
-
<div>
|
| 365 |
-
<label className="text-xs font-medium text-[var(--warm-charcoal)] mb-1 block">Jawaban Benar</label>
|
| 366 |
-
<Input
|
| 367 |
-
value={editCorrectAnswer}
|
| 368 |
-
onChange={(e) => setEditCorrectAnswer(e.target.value)}
|
| 369 |
-
className="h-9 text-sm"
|
| 370 |
-
/>
|
| 371 |
-
</div>
|
| 372 |
-
<div>
|
| 373 |
-
<label className="text-xs font-medium text-[var(--warm-charcoal)] mb-1 block">Penjelasan</label>
|
| 374 |
-
<textarea
|
| 375 |
-
value={editExplanation}
|
| 376 |
-
onChange={(e) => setEditExplanation(e.target.value)}
|
| 377 |
-
className="w-full min-h-[60px] p-2 rounded-[var(--radius-md)] border-2 border-[var(--oat-border)] bg-[var(--pure-white)] text-sm text-[var(--clay-black)] resize-y"
|
| 378 |
-
/>
|
| 379 |
-
</div>
|
| 380 |
-
<div className="flex gap-2">
|
| 381 |
-
<Button
|
| 382 |
-
onClick={handleSaveEdit}
|
| 383 |
-
disabled={updateQuestionMutation.isPending}
|
| 384 |
-
className="h-9 text-sm bg-[var(--clay-black)] text-[var(--pure-white)] hover:bg-[var(--warm-charcoal)] rounded-[var(--radius-lg)]"
|
| 385 |
-
>
|
| 386 |
-
<MaterialIcon name="save" className="text-sm mr-1" />
|
| 387 |
-
Simpan
|
| 388 |
-
</Button>
|
| 389 |
-
<Button
|
| 390 |
-
variant="outline"
|
| 391 |
-
onClick={() => setIsEditing(false)}
|
| 392 |
-
className="h-9 text-sm rounded-[var(--radius-lg)] border-2 border-[var(--oat-border)]"
|
| 393 |
-
>
|
| 394 |
-
Batal
|
| 395 |
-
</Button>
|
| 396 |
-
</div>
|
| 397 |
-
</div>
|
| 398 |
-
)}
|
| 399 |
-
|
| 400 |
-
<div className="flex flex-wrap gap-4 text-sm">
|
| 401 |
-
<div>
|
| 402 |
-
<span className="text-[var(--warm-silver)]">Jawaban Anda:</span>{" "}
|
| 403 |
-
<span
|
| 404 |
-
className={`font-semibold ${
|
| 405 |
-
isCorrect === true
|
| 406 |
-
? "text-[var(--matcha-700)]"
|
| 407 |
-
: isCorrect === false
|
| 408 |
-
? "text-[var(--pomegranate-600)]"
|
| 409 |
-
: "text-[var(--warm-charcoal)]"
|
| 410 |
-
}`}
|
| 411 |
-
>
|
| 412 |
-
{userAnswer}
|
| 413 |
-
</span>
|
| 414 |
-
</div>
|
| 415 |
-
{ans?.partialScore != null && ans?.partialScore < 100 && (
|
| 416 |
-
<div>
|
| 417 |
-
<span className="text-[var(--warm-silver)]">Skor Parsial:</span>{" "}
|
| 418 |
-
<span className="font-semibold text-[var(--lemon-700)]">
|
| 419 |
-
{ans.partialScore}%
|
| 420 |
-
</span>
|
| 421 |
-
</div>
|
| 422 |
-
)}
|
| 423 |
-
{(isCorrect === false || isCorrect === null) && (
|
| 424 |
-
<div>
|
| 425 |
-
<span className="text-[var(--warm-silver)]">Jawaban Benar:</span>{" "}
|
| 426 |
-
<span className="font-semibold text-[var(--matcha-700)]">
|
| 427 |
-
{q.correctAnswer}
|
| 428 |
-
</span>
|
| 429 |
-
</div>
|
| 430 |
-
)}
|
| 431 |
-
{ans?.timeSpentSec !== null && ans?.timeSpentSec !== undefined && (
|
| 432 |
-
<div className="flex items-center gap-1 text-[var(--warm-silver)]">
|
| 433 |
-
<MaterialIcon name="timer" className="text-xs" />
|
| 434 |
-
<span>{formatTime(ans.timeSpentSec)}</span>
|
| 435 |
-
</div>
|
| 436 |
-
)}
|
| 437 |
-
</div>
|
| 438 |
-
|
| 439 |
-
{q.explanation && !isEditing && (
|
| 440 |
-
<div className="bg-[var(--oat-light)] rounded-[var(--radius-lg)] p-3 text-sm text-[var(--warm-charcoal)]">
|
| 441 |
-
<span className="font-semibold text-[var(--clay-black)]">Penjelasan:</span>{" "}
|
| 442 |
-
{q.explanation}
|
| 443 |
-
</div>
|
| 444 |
-
)}
|
| 445 |
-
|
| 446 |
-
{/* Thumbs Feedback */}
|
| 447 |
-
<div className="flex items-center gap-3 pt-1">
|
| 448 |
-
<button
|
| 449 |
-
onClick={() => voteMutation.mutate({ questionId: q.id, type: "up" })}
|
| 450 |
-
className={`flex items-center gap-1 px-3 py-1.5 rounded-full text-xs font-medium transition-all ${
|
| 451 |
-
feedbackQuery.data?.myFeedback === "up"
|
| 452 |
-
? "bg-[var(--matcha-300)] text-[var(--matcha-800)]"
|
| 453 |
-
: "bg-[var(--oat-light)] text-[var(--warm-charcoal)] hover:bg-[var(--matcha-300)] hover:text-[var(--matcha-800)]"
|
| 454 |
-
}`}
|
| 455 |
-
>
|
| 456 |
-
<MaterialIcon name="thumb_up" className="text-sm" />
|
| 457 |
-
<span>{feedbackQuery.data?.up ?? 0}</span>
|
| 458 |
-
</button>
|
| 459 |
-
<button
|
| 460 |
-
onClick={() => voteMutation.mutate({ questionId: q.id, type: "down" })}
|
| 461 |
-
className={`flex items-center gap-1 px-3 py-1.5 rounded-full text-xs font-medium transition-all ${
|
| 462 |
-
feedbackQuery.data?.myFeedback === "down"
|
| 463 |
-
? "bg-[var(--pomegranate-100)] text-[var(--pomegranate-600)]"
|
| 464 |
-
: "bg-[var(--oat-light)] text-[var(--warm-charcoal)] hover:bg-[var(--pomegranate-100)] hover:text-[var(--pomegranate-600)]"
|
| 465 |
-
}`}
|
| 466 |
-
>
|
| 467 |
-
<MaterialIcon name="thumb_down" className="text-sm" />
|
| 468 |
-
<span>{feedbackQuery.data?.down ?? 0}</span>
|
| 469 |
-
</button>
|
| 470 |
-
</div>
|
| 471 |
-
</div>
|
| 472 |
-
</CardContent>
|
| 473 |
-
</Card>
|
| 474 |
-
);
|
| 475 |
-
}
|
|
|
|
| 1 |
+
import { useState, useMemo } from "react";
|
| 2 |
import { useQuery, useMutation } 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 { Button } from "@labas/ui/components/button";
|
| 7 |
import { Card, CardContent } from "@labas/ui/components/card";
|
|
|
|
| 8 |
import { MaterialIcon } from "@/components/ui/MaterialIcon";
|
| 9 |
import { formatTime } from "@/lib/time";
|
| 10 |
+
import { QuestionReviewCard } from "@/components/attempt/QuestionReviewCard";
|
| 11 |
+
import { ReviewFilterBar } from "@/components/attempt/ReviewFilterBar";
|
| 12 |
+
import { SkillBreakdown } from "@/components/attempt/SkillBreakdown";
|
| 13 |
+
|
| 14 |
+
type FilterStatus = "all" | "wrong" | "correct" | "marked";
|
| 15 |
|
| 16 |
export const Route = createFileRoute("/attempt/$id")({
|
| 17 |
component: AttemptResultComponent,
|
|
|
|
| 51 |
? Math.round((new Date(attempt.finishedAt).getTime() - new Date(attempt.startedAt).getTime()) / 1000)
|
| 52 |
: 0;
|
| 53 |
|
| 54 |
+
// ── Filter state ──
|
| 55 |
+
const [filterStatus, setFilterStatus] = useState<FilterStatus>("all");
|
| 56 |
+
const [filterSkills, setFilterSkills] = useState<string[]>([]);
|
| 57 |
+
const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set());
|
| 58 |
+
const [allExpanded, setAllExpanded] = useState(false);
|
| 59 |
+
|
| 60 |
+
// ── Flatten all questions with section context ──
|
| 61 |
+
const allQuestions = useMemo(() => {
|
| 62 |
+
if (!attempt?.sections) return [];
|
| 63 |
+
return attempt.sections.flatMap((sec: any, secIdx: number) =>
|
| 64 |
+
sec.questions.map((q: any, qIdx: number) => ({
|
| 65 |
+
q,
|
| 66 |
+
secIdx,
|
| 67 |
+
qIdx,
|
| 68 |
+
ans: sec.answers.find((a: any) => a.questionId === q.id) ?? null,
|
| 69 |
+
})),
|
| 70 |
+
);
|
| 71 |
+
}, [attempt]);
|
| 72 |
+
|
| 73 |
+
// ── Filtered questions ──
|
| 74 |
+
const partialCount = useMemo(
|
| 75 |
+
() => allQuestions.filter(({ ans }: any) => ans?.partialScore != null && ans?.partialScore < 100).length,
|
| 76 |
+
[allQuestions],
|
| 77 |
+
);
|
| 78 |
+
|
| 79 |
+
const filteredQuestions = useMemo(() => {
|
| 80 |
+
return allQuestions.filter(({ q, ans }: any) => {
|
| 81 |
+
if (filterStatus === "wrong" && ans?.isCorrect !== false) return false;
|
| 82 |
+
if (filterStatus === "correct" && ans?.isCorrect !== true) return false;
|
| 83 |
+
if (filterStatus === "marked") {
|
| 84 |
+
const hasPartial = ans?.partialScore != null && ans?.partialScore < 100;
|
| 85 |
+
if (!hasPartial) return false;
|
| 86 |
+
}
|
| 87 |
+
if (filterSkills.length > 0) {
|
| 88 |
+
const tags: string[] = q.skillTags ?? [];
|
| 89 |
+
if (!filterSkills.some((s) => tags.includes(s))) return false;
|
| 90 |
+
}
|
| 91 |
+
return true;
|
| 92 |
+
});
|
| 93 |
+
}, [allQuestions, filterStatus, filterSkills]);
|
| 94 |
+
|
| 95 |
+
// ── Expand/collapse ──
|
| 96 |
+
const toggleAllExpanded = () => {
|
| 97 |
+
const next = !allExpanded;
|
| 98 |
+
setAllExpanded(next);
|
| 99 |
+
if (next) {
|
| 100 |
+
setExpandedIds(new Set(filteredQuestions.map(({ q }: any) => q.id)));
|
| 101 |
+
} else {
|
| 102 |
+
setExpandedIds(new Set());
|
| 103 |
+
}
|
| 104 |
+
};
|
| 105 |
+
|
| 106 |
+
const toggleExpand = (id: string) => {
|
| 107 |
+
setExpandedIds((prev) => {
|
| 108 |
+
const next = new Set(prev);
|
| 109 |
+
if (next.has(id)) next.delete(id);
|
| 110 |
+
else next.add(id);
|
| 111 |
+
return next;
|
| 112 |
+
});
|
| 113 |
+
setAllExpanded(false);
|
| 114 |
+
};
|
| 115 |
+
|
| 116 |
+
const toggleSkill = (skill: string) => {
|
| 117 |
+
setFilterSkills((prev) =>
|
| 118 |
+
prev.includes(skill) ? prev.filter((s) => s !== skill) : [skill],
|
| 119 |
+
);
|
| 120 |
+
setFilterStatus("all");
|
| 121 |
+
};
|
| 122 |
+
|
| 123 |
if (attemptQuery.isLoading) {
|
| 124 |
return (
|
| 125 |
<div className="min-h-screen pt-8 pb-32 px-6 md:px-12 lg:px-16 max-w-4xl mx-auto bg-[var(--warm-cream)]">
|
|
|
|
| 197 |
{attempt.totalScore ?? 0} benar
|
| 198 |
</span>
|
| 199 |
<span className="flex items-center gap-1">
|
| 200 |
+
<MaterialIcon name="cancel" className="text-sm text-[var(--pomegranate-400)]" />
|
| 201 |
{(attempt.maxScore ?? 0) - (attempt.totalScore ?? 0)} salah
|
| 202 |
</span>
|
| 203 |
</div>
|
|
|
|
| 248 |
<p className="text-sm text-[var(--warm-charcoal)]">
|
| 249 |
{sec.score ?? 0}/{sec.maxScore ?? 0} benar
|
| 250 |
</p>
|
| 251 |
+
{sec.timeSpentSec != null && (
|
| 252 |
<p className="text-xs text-[var(--warm-silver)] mt-1 flex items-center gap-1">
|
| 253 |
<MaterialIcon name="timer" className="text-xs" />
|
| 254 |
{formatTime(sec.timeSpentSec)}
|
|
|
|
| 266 |
</div>
|
| 267 |
</div>
|
| 268 |
|
| 269 |
+
{/* Skill Breakdown */}
|
| 270 |
+
<div className="mb-8">
|
| 271 |
+
<h2 className="text-xl font-headline font-bold text-[var(--clay-black)] mb-4">
|
| 272 |
+
Kemampuan per Topik
|
| 273 |
+
<span className="ml-2 text-sm font-normal text-[var(--warm-charcoal)]">Klik topik untuk filter soal</span>
|
| 274 |
+
</h2>
|
| 275 |
+
<SkillBreakdown
|
| 276 |
+
questions={allQuestions.map(({ q, ans }: any) => ({
|
| 277 |
+
skillTags: q.skillTags,
|
| 278 |
+
isCorrect: ans?.isCorrect,
|
| 279 |
+
}))}
|
| 280 |
+
onSkillClick={toggleSkill}
|
| 281 |
+
activeSkills={filterSkills}
|
| 282 |
+
/>
|
| 283 |
+
</div>
|
| 284 |
+
|
| 285 |
{/* Question Review */}
|
| 286 |
<div className="mb-8">
|
| 287 |
+
<h2 className="text-xl font-headline font-bold text-[var(--clay-black)] mb-4">
|
| 288 |
+
Pembahasan Soal
|
| 289 |
+
{filterStatus !== "all" && (
|
| 290 |
+
<span className="ml-2 text-sm font-normal text-[var(--warm-charcoal)]">
|
| 291 |
+
({filteredQuestions.length} soal)
|
| 292 |
+
</span>
|
| 293 |
+
)}
|
| 294 |
+
</h2>
|
| 295 |
+
|
| 296 |
+
<ReviewFilterBar
|
| 297 |
+
filterStatus={filterStatus}
|
| 298 |
+
setFilterStatus={setFilterStatus}
|
| 299 |
+
filterSkills={filterSkills}
|
| 300 |
+
setFilterSkills={setFilterSkills}
|
| 301 |
+
questions={allQuestions.map(({ q, ans }: any) => ({
|
| 302 |
+
skillTags: q.skillTags ?? [],
|
| 303 |
+
isCorrect: ans?.isCorrect,
|
| 304 |
+
}))}
|
| 305 |
+
markedCount={partialCount}
|
| 306 |
+
allExpanded={allExpanded}
|
| 307 |
+
onToggleAllExpanded={toggleAllExpanded}
|
| 308 |
+
/>
|
| 309 |
+
|
| 310 |
+
<div className="space-y-3">
|
| 311 |
+
{filteredQuestions.map(({ q, secIdx, qIdx, ans }: any) => (
|
| 312 |
+
<QuestionReviewCard
|
| 313 |
+
key={q.id}
|
| 314 |
+
q={q}
|
| 315 |
+
secIdx={secIdx}
|
| 316 |
+
qIdx={qIdx}
|
| 317 |
+
ans={ans}
|
| 318 |
+
userId={session?.user.id}
|
| 319 |
+
isExpanded={expandedIds.has(q.id)}
|
| 320 |
+
onToggleExpand={() => toggleExpand(q.id)}
|
| 321 |
+
/>
|
| 322 |
+
))}
|
| 323 |
+
{filteredQuestions.length === 0 && (
|
| 324 |
+
<div className="text-center py-12">
|
| 325 |
+
<MaterialIcon name="search_off" className="text-4xl text-[var(--warm-silver)] mx-auto mb-3" />
|
| 326 |
+
<p className="text-sm text-[var(--warm-charcoal)]">Tidak ada soal yang sesuai filter.</p>
|
| 327 |
+
<button
|
| 328 |
+
onClick={() => { setFilterStatus("all"); setFilterSkills([]); }}
|
| 329 |
+
className="text-sm text-[var(--matcha-600)] font-semibold mt-2 hover:underline"
|
| 330 |
+
>
|
| 331 |
+
Reset filter
|
| 332 |
+
</button>
|
| 333 |
+
</div>
|
| 334 |
)}
|
| 335 |
</div>
|
| 336 |
</div>
|
|
|
|
| 356 |
</div>
|
| 357 |
);
|
| 358 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
apps/web/src/routes/forgot-password.tsx
ADDED
|
@@ -0,0 +1,253 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { createFileRoute, Link, useNavigate } from "@tanstack/react-router";
|
| 2 |
+
import { useState, useEffect, useRef } from "react";
|
| 3 |
+
import { useMutation } from "@tanstack/react-query";
|
| 4 |
+
import { Button } from "@labas/ui/components/button";
|
| 5 |
+
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@labas/ui/components/card";
|
| 6 |
+
import { Input } from "@labas/ui/components/input";
|
| 7 |
+
import { Label } from "@labas/ui/components/label";
|
| 8 |
+
import { toast } from "sonner";
|
| 9 |
+
import { trpc } from "@/utils/trpc";
|
| 10 |
+
|
| 11 |
+
export const Route = createFileRoute("/forgot-password")({
|
| 12 |
+
component: RouteComponent,
|
| 13 |
+
});
|
| 14 |
+
|
| 15 |
+
function StepEmail({ onNext }: { onNext: (email: string) => void }) {
|
| 16 |
+
const [email, setEmail] = useState("");
|
| 17 |
+
const sendMutation = useMutation(trpc.verification.sendPasswordResetOtp.mutationOptions());
|
| 18 |
+
|
| 19 |
+
const handleSubmit = async (e: React.FormEvent) => {
|
| 20 |
+
e.preventDefault();
|
| 21 |
+
if (!email) return;
|
| 22 |
+
try {
|
| 23 |
+
await sendMutation.mutateAsync({ email });
|
| 24 |
+
toast.success("Kode OTP telah dikirim ke email Anda");
|
| 25 |
+
onNext(email);
|
| 26 |
+
} catch (err: any) {
|
| 27 |
+
toast.error(err.message || "Gagal mengirim OTP");
|
| 28 |
+
}
|
| 29 |
+
};
|
| 30 |
+
|
| 31 |
+
return (
|
| 32 |
+
<Card className="mx-auto w-full max-w-md shadow-lg border-muted rounded-xl">
|
| 33 |
+
<CardHeader className="space-y-1 text-center">
|
| 34 |
+
<CardTitle className="text-3xl font-bold tracking-tight">Lupa Password</CardTitle>
|
| 35 |
+
<CardDescription>
|
| 36 |
+
Masukkan email Anda untuk menerima kode reset password
|
| 37 |
+
</CardDescription>
|
| 38 |
+
</CardHeader>
|
| 39 |
+
<CardContent className="space-y-4">
|
| 40 |
+
<form onSubmit={handleSubmit} className="space-y-4">
|
| 41 |
+
<div className="space-y-2">
|
| 42 |
+
<Label htmlFor="email">Email</Label>
|
| 43 |
+
<Input
|
| 44 |
+
id="email"
|
| 45 |
+
type="email"
|
| 46 |
+
value={email}
|
| 47 |
+
onChange={(e) => setEmail(e.target.value)}
|
| 48 |
+
placeholder="nama@email.com"
|
| 49 |
+
required
|
| 50 |
+
/>
|
| 51 |
+
</div>
|
| 52 |
+
|
| 53 |
+
<Button type="submit" className="w-full" disabled={sendMutation.isPending || !email}>
|
| 54 |
+
{sendMutation.isPending ? "Mengirim..." : "Kirim Kode"}
|
| 55 |
+
</Button>
|
| 56 |
+
</form>
|
| 57 |
+
|
| 58 |
+
<div className="text-center">
|
| 59 |
+
<Link to="/login" className="text-sm text-muted-foreground hover:text-primary">
|
| 60 |
+
Kembali ke halaman masuk
|
| 61 |
+
</Link>
|
| 62 |
+
</div>
|
| 63 |
+
</CardContent>
|
| 64 |
+
</Card>
|
| 65 |
+
);
|
| 66 |
+
}
|
| 67 |
+
|
| 68 |
+
function StepReset({ email }: { email: string }) {
|
| 69 |
+
const navigate = useNavigate();
|
| 70 |
+
const [otp, setOtp] = useState(["", "", "", "", "", ""]);
|
| 71 |
+
const [newPassword, setNewPassword] = useState("");
|
| 72 |
+
const [confirmPassword, setConfirmPassword] = useState("");
|
| 73 |
+
const [countdown, setCountdown] = useState(60);
|
| 74 |
+
const inputRefs = useRef<(HTMLInputElement | null)[]>([]);
|
| 75 |
+
|
| 76 |
+
const resetMutation = useMutation(trpc.verification.resetPassword.mutationOptions());
|
| 77 |
+
const resendMutation = useMutation(trpc.verification.sendPasswordResetOtp.mutationOptions());
|
| 78 |
+
|
| 79 |
+
useEffect(() => {
|
| 80 |
+
inputRefs.current[0]?.focus();
|
| 81 |
+
}, []);
|
| 82 |
+
|
| 83 |
+
useEffect(() => {
|
| 84 |
+
if (countdown > 0) {
|
| 85 |
+
const timer = setTimeout(() => setCountdown(countdown - 1), 1000);
|
| 86 |
+
return () => clearTimeout(timer);
|
| 87 |
+
}
|
| 88 |
+
}, [countdown]);
|
| 89 |
+
|
| 90 |
+
const handleOtpChange = (index: number, value: string) => {
|
| 91 |
+
if (!/^\d?$/.test(value)) return;
|
| 92 |
+
const newOtp = [...otp];
|
| 93 |
+
newOtp[index] = value;
|
| 94 |
+
setOtp(newOtp);
|
| 95 |
+
if (value && index < 5) {
|
| 96 |
+
inputRefs.current[index + 1]?.focus();
|
| 97 |
+
}
|
| 98 |
+
};
|
| 99 |
+
|
| 100 |
+
const handleKeyDown = (index: number, e: React.KeyboardEvent) => {
|
| 101 |
+
if (e.key === "Backspace" && !otp[index] && index > 0) {
|
| 102 |
+
inputRefs.current[index - 1]?.focus();
|
| 103 |
+
}
|
| 104 |
+
};
|
| 105 |
+
|
| 106 |
+
const handlePaste = (e: React.ClipboardEvent) => {
|
| 107 |
+
e.preventDefault();
|
| 108 |
+
const pasted = e.clipboardData.getData("text").replace(/\D/g, "").slice(0, 6);
|
| 109 |
+
const newOtp = [...otp];
|
| 110 |
+
for (let i = 0; i < pasted.length; i++) {
|
| 111 |
+
newOtp[i] = pasted[i];
|
| 112 |
+
}
|
| 113 |
+
setOtp(newOtp);
|
| 114 |
+
const nextIndex = Math.min(pasted.length, 5);
|
| 115 |
+
inputRefs.current[nextIndex]?.focus();
|
| 116 |
+
};
|
| 117 |
+
|
| 118 |
+
const handleResend = async () => {
|
| 119 |
+
try {
|
| 120 |
+
await resendMutation.mutateAsync({ email });
|
| 121 |
+
toast.success("Kode OTP telah dikirim ulang");
|
| 122 |
+
setCountdown(60);
|
| 123 |
+
} catch (err: any) {
|
| 124 |
+
toast.error(err.message || "Gagal mengirim ulang OTP");
|
| 125 |
+
}
|
| 126 |
+
};
|
| 127 |
+
|
| 128 |
+
const handleSubmit = async (e: React.FormEvent) => {
|
| 129 |
+
e.preventDefault();
|
| 130 |
+
const code = otp.join("");
|
| 131 |
+
|
| 132 |
+
if (code.length !== 6) {
|
| 133 |
+
toast.error("Masukkan 6 digit kode OTP");
|
| 134 |
+
return;
|
| 135 |
+
}
|
| 136 |
+
if (newPassword.length < 8) {
|
| 137 |
+
toast.error("Password minimal 8 karakter");
|
| 138 |
+
return;
|
| 139 |
+
}
|
| 140 |
+
if (newPassword !== confirmPassword) {
|
| 141 |
+
toast.error("Password tidak cocok");
|
| 142 |
+
return;
|
| 143 |
+
}
|
| 144 |
+
|
| 145 |
+
try {
|
| 146 |
+
await resetMutation.mutateAsync({ email, otp: code, newPassword });
|
| 147 |
+
toast.success("Password berhasil direset! Silakan masuk.");
|
| 148 |
+
navigate({ to: "/login" });
|
| 149 |
+
} catch (err: any) {
|
| 150 |
+
toast.error(err.message || "Gagal mereset password");
|
| 151 |
+
setOtp(["", "", "", "", "", ""]);
|
| 152 |
+
inputRefs.current[0]?.focus();
|
| 153 |
+
}
|
| 154 |
+
};
|
| 155 |
+
|
| 156 |
+
const isMutating = resetMutation.isPending || resendMutation.isPending;
|
| 157 |
+
|
| 158 |
+
return (
|
| 159 |
+
<Card className="mx-auto w-full max-w-md shadow-lg border-muted rounded-xl">
|
| 160 |
+
<CardHeader className="space-y-1 text-center">
|
| 161 |
+
<CardTitle className="text-3xl font-bold tracking-tight">Reset Password</CardTitle>
|
| 162 |
+
<CardDescription>
|
| 163 |
+
Masukkan kode dari email dan password baru Anda
|
| 164 |
+
</CardDescription>
|
| 165 |
+
</CardHeader>
|
| 166 |
+
<CardContent>
|
| 167 |
+
<form onSubmit={handleSubmit} className="space-y-4">
|
| 168 |
+
<div className="space-y-2">
|
| 169 |
+
<Label>Kode OTP</Label>
|
| 170 |
+
<div className="flex justify-center gap-2" onPaste={handlePaste}>
|
| 171 |
+
{otp.map((digit, i) => (
|
| 172 |
+
<Input
|
| 173 |
+
key={i}
|
| 174 |
+
ref={(el) => { inputRefs.current[i] = el; }}
|
| 175 |
+
type="text"
|
| 176 |
+
inputMode="numeric"
|
| 177 |
+
maxLength={1}
|
| 178 |
+
value={digit}
|
| 179 |
+
onChange={(e) => handleOtpChange(i, e.target.value)}
|
| 180 |
+
onKeyDown={(e) => handleKeyDown(i, e)}
|
| 181 |
+
className="w-12 h-14 text-center text-xl font-bold"
|
| 182 |
+
/>
|
| 183 |
+
))}
|
| 184 |
+
</div>
|
| 185 |
+
</div>
|
| 186 |
+
|
| 187 |
+
<div className="space-y-2">
|
| 188 |
+
<Label htmlFor="newPassword">Password Baru</Label>
|
| 189 |
+
<Input
|
| 190 |
+
id="newPassword"
|
| 191 |
+
type="password"
|
| 192 |
+
value={newPassword}
|
| 193 |
+
onChange={(e) => setNewPassword(e.target.value)}
|
| 194 |
+
placeholder="Minimal 8 karakter"
|
| 195 |
+
/>
|
| 196 |
+
</div>
|
| 197 |
+
|
| 198 |
+
<div className="space-y-2">
|
| 199 |
+
<Label htmlFor="confirmPassword">Konfirmasi Password Baru</Label>
|
| 200 |
+
<Input
|
| 201 |
+
id="confirmPassword"
|
| 202 |
+
type="password"
|
| 203 |
+
value={confirmPassword}
|
| 204 |
+
onChange={(e) => setConfirmPassword(e.target.value)}
|
| 205 |
+
placeholder="Ulangi password baru"
|
| 206 |
+
/>
|
| 207 |
+
</div>
|
| 208 |
+
|
| 209 |
+
<Button
|
| 210 |
+
type="submit"
|
| 211 |
+
className="w-full"
|
| 212 |
+
disabled={isMutating || otp.join("").length !== 6 || !newPassword || !confirmPassword}
|
| 213 |
+
>
|
| 214 |
+
{resetMutation.isPending ? "Menyimpan..." : "Reset Password"}
|
| 215 |
+
</Button>
|
| 216 |
+
|
| 217 |
+
<div className="text-center text-sm text-muted-foreground">
|
| 218 |
+
Tidak menerima kode?{" "}
|
| 219 |
+
<Button
|
| 220 |
+
type="button"
|
| 221 |
+
variant="link"
|
| 222 |
+
className="p-0 h-auto font-semibold"
|
| 223 |
+
disabled={resendMutation.isPending || countdown > 0}
|
| 224 |
+
onClick={handleResend}
|
| 225 |
+
>
|
| 226 |
+
{countdown > 0 ? `Kirim ulang (${countdown}s)` : "Kirim Ulang"}
|
| 227 |
+
</Button>
|
| 228 |
+
</div>
|
| 229 |
+
</form>
|
| 230 |
+
</CardContent>
|
| 231 |
+
</Card>
|
| 232 |
+
);
|
| 233 |
+
}
|
| 234 |
+
|
| 235 |
+
function RouteComponent() {
|
| 236 |
+
const [email, setEmail] = useState<string | null>(null);
|
| 237 |
+
|
| 238 |
+
if (!email) {
|
| 239 |
+
return (
|
| 240 |
+
<div className="flex h-screen w-full items-center justify-center bg-muted/30">
|
| 241 |
+
<img src="/logo.png" alt="Labas Logo" className="h-20 w-auto mb-8 absolute top-8" />
|
| 242 |
+
<StepEmail onNext={setEmail} />
|
| 243 |
+
</div>
|
| 244 |
+
);
|
| 245 |
+
}
|
| 246 |
+
|
| 247 |
+
return (
|
| 248 |
+
<div className="flex h-screen w-full items-center justify-center bg-muted/30">
|
| 249 |
+
<img src="/logo.png" alt="Labas Logo" className="h-20 w-auto mb-8 absolute top-8" />
|
| 250 |
+
<StepReset email={email} />
|
| 251 |
+
</div>
|
| 252 |
+
);
|
| 253 |
+
}
|
apps/web/src/routes/generate.tsx
CHANGED
|
@@ -107,6 +107,8 @@ function RouteComponent() {
|
|
| 107 |
const [weaknessAlign, setWeaknessAlign] = useState(75);
|
| 108 |
const [mode, setMode] = useState<"quick" | "agentic">("quick");
|
| 109 |
|
|
|
|
|
|
|
| 110 |
useEffect(() => {
|
| 111 |
setSelectedFormats((prev) => {
|
| 112 |
const valid = prev.filter((f) =>
|
|
@@ -119,6 +121,13 @@ function RouteComponent() {
|
|
| 119 |
});
|
| 120 |
}, [examType]);
|
| 121 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 122 |
const generate = useMutation({
|
| 123 |
...trpc.ai.generate.mutationOptions(),
|
| 124 |
onSuccess: (data) => {
|
|
@@ -257,6 +266,7 @@ function RouteComponent() {
|
|
| 257 |
<Link to="/settings">
|
| 258 |
<Button
|
| 259 |
variant="outline"
|
|
|
|
| 260 |
className="rounded-[var(--radius-lg)] border-2 border-[var(--oat-border)] clay-hover"
|
| 261 |
>
|
| 262 |
<MaterialIcon name="settings" className="mr-1" />
|
|
@@ -337,26 +347,32 @@ function RouteComponent() {
|
|
| 337 |
<span className="ml-2 text-sm font-normal text-[var(--warm-charcoal)]">{questionCount} soal</span>
|
| 338 |
</label>
|
| 339 |
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
| 340 |
-
{QUESTION_COUNT_PRESETS.map((p) =>
|
|
|
|
|
|
|
| 341 |
<button
|
| 342 |
key={p.value}
|
| 343 |
onClick={() => setQuestionCount(p.value)}
|
|
|
|
| 344 |
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] ${
|
| 345 |
-
|
| 346 |
-
? "bg-[var(--
|
| 347 |
-
:
|
|
|
|
|
|
|
| 348 |
}`}
|
| 349 |
>
|
| 350 |
<span>{p.label}</span>
|
| 351 |
<span className={`text-xs ${questionCount === p.value ? "text-[var(--pure-white)]/70" : "text-[var(--warm-charcoal)]/70"}`}>{p.desc}</span>
|
| 352 |
</button>
|
| 353 |
-
)
|
|
|
|
| 354 |
</div>
|
| 355 |
<div className="flex items-center gap-3 mt-1">
|
| 356 |
<span className="text-xs font-medium text-[var(--warm-charcoal)] whitespace-nowrap">Custom:</span>
|
| 357 |
<input
|
| 358 |
type="range"
|
| 359 |
-
min={1}
|
| 360 |
max={40}
|
| 361 |
value={questionCount}
|
| 362 |
onChange={(e) => setQuestionCount(Number(e.target.value))}
|
|
@@ -514,6 +530,7 @@ function RouteComponent() {
|
|
| 514 |
error={error}
|
| 515 |
onGenerate={handleGenerate}
|
| 516 |
onDismissError={() => setError(null)}
|
|
|
|
| 517 |
/>
|
| 518 |
</div>
|
| 519 |
</div>
|
|
|
|
| 107 |
const [weaknessAlign, setWeaknessAlign] = useState(75);
|
| 108 |
const [mode, setMode] = useState<"quick" | "agentic">("quick");
|
| 109 |
|
| 110 |
+
const isReadingAndWriting = selectedSections.includes("READING") && selectedSections.includes("WRITING");
|
| 111 |
+
|
| 112 |
useEffect(() => {
|
| 113 |
setSelectedFormats((prev) => {
|
| 114 |
const valid = prev.filter((f) =>
|
|
|
|
| 121 |
});
|
| 122 |
}, [examType]);
|
| 123 |
|
| 124 |
+
useEffect(() => {
|
| 125 |
+
if (isReadingAndWriting) {
|
| 126 |
+
if (questionCount < 20) setQuestionCount(20);
|
| 127 |
+
if (mode === "quick") setMode("agentic");
|
| 128 |
+
}
|
| 129 |
+
}, [isReadingAndWriting]);
|
| 130 |
+
|
| 131 |
const generate = useMutation({
|
| 132 |
...trpc.ai.generate.mutationOptions(),
|
| 133 |
onSuccess: (data) => {
|
|
|
|
| 266 |
<Link to="/settings">
|
| 267 |
<Button
|
| 268 |
variant="outline"
|
| 269 |
+
size="xl"
|
| 270 |
className="rounded-[var(--radius-lg)] border-2 border-[var(--oat-border)] clay-hover"
|
| 271 |
>
|
| 272 |
<MaterialIcon name="settings" className="mr-1" />
|
|
|
|
| 347 |
<span className="ml-2 text-sm font-normal text-[var(--warm-charcoal)]">{questionCount} soal</span>
|
| 348 |
</label>
|
| 349 |
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
| 350 |
+
{QUESTION_COUNT_PRESETS.map((p) => {
|
| 351 |
+
const isDisabled = isReadingAndWriting && (p.value === 5 || p.value === 10);
|
| 352 |
+
return (
|
| 353 |
<button
|
| 354 |
key={p.value}
|
| 355 |
onClick={() => setQuestionCount(p.value)}
|
| 356 |
+
disabled={isDisabled}
|
| 357 |
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] ${
|
| 358 |
+
isDisabled
|
| 359 |
+
? "bg-[var(--oat-light)] text-[var(--warm-silver)] cursor-not-allowed opacity-50 border-2 border-[var(--oat-border)]"
|
| 360 |
+
: questionCount === p.value
|
| 361 |
+
? "bg-[var(--clay-black)] text-[var(--pure-white)] clay-shadow"
|
| 362 |
+
: "bg-[var(--pure-white)] text-[var(--warm-charcoal)] hover:bg-[var(--oat-light)] border-2 border-[var(--oat-border)]"
|
| 363 |
}`}
|
| 364 |
>
|
| 365 |
<span>{p.label}</span>
|
| 366 |
<span className={`text-xs ${questionCount === p.value ? "text-[var(--pure-white)]/70" : "text-[var(--warm-charcoal)]/70"}`}>{p.desc}</span>
|
| 367 |
</button>
|
| 368 |
+
);
|
| 369 |
+
})}
|
| 370 |
</div>
|
| 371 |
<div className="flex items-center gap-3 mt-1">
|
| 372 |
<span className="text-xs font-medium text-[var(--warm-charcoal)] whitespace-nowrap">Custom:</span>
|
| 373 |
<input
|
| 374 |
type="range"
|
| 375 |
+
min={isReadingAndWriting ? 20 : 1}
|
| 376 |
max={40}
|
| 377 |
value={questionCount}
|
| 378 |
onChange={(e) => setQuestionCount(Number(e.target.value))}
|
|
|
|
| 530 |
error={error}
|
| 531 |
onGenerate={handleGenerate}
|
| 532 |
onDismissError={() => setError(null)}
|
| 533 |
+
disableQuick={isReadingAndWriting}
|
| 534 |
/>
|
| 535 |
</div>
|
| 536 |
</div>
|
apps/web/src/routes/index.tsx
CHANGED
|
@@ -13,6 +13,9 @@ export const Route = createFileRoute("/")({
|
|
| 13 |
if (!session.data) {
|
| 14 |
throw redirect({ to: "/landing" });
|
| 15 |
}
|
|
|
|
|
|
|
|
|
|
| 16 |
if (!session.data.user.image) {
|
| 17 |
throw redirect({ to: "/setup-avatar" });
|
| 18 |
}
|
|
|
|
| 13 |
if (!session.data) {
|
| 14 |
throw redirect({ to: "/landing" });
|
| 15 |
}
|
| 16 |
+
if (!session.data.user.emailVerified) {
|
| 17 |
+
throw redirect({ to: "/verify-email", search: { email: session.data.user.email } });
|
| 18 |
+
}
|
| 19 |
if (!session.data.user.image) {
|
| 20 |
throw redirect({ to: "/setup-avatar" });
|
| 21 |
}
|
apps/web/src/routes/packages.tsx
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
|
|
| 1 |
import { useQuery, useMutation } from "@tanstack/react-query";
|
| 2 |
import { createFileRoute, redirect, Link, useNavigate } from "@tanstack/react-router";
|
| 3 |
import { z } from "zod";
|
|
@@ -101,6 +102,38 @@ function PackagesComponent() {
|
|
| 101 |
updateMutation.mutate({ id: pkgId, isPublic: !current });
|
| 102 |
};
|
| 103 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 104 |
const setTab = (newTab: Tab) => {
|
| 105 |
navigate({ search: { tab: newTab, search: "", examType: "", page: 1 } });
|
| 106 |
};
|
|
@@ -194,6 +227,60 @@ function PackagesComponent() {
|
|
| 194 |
</Select>
|
| 195 |
</div>
|
| 196 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 197 |
{/* Results */}
|
| 198 |
{query.isLoading ? (
|
| 199 |
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
|
@@ -230,16 +317,42 @@ function PackagesComponent() {
|
|
| 230 |
<div data-tour="packages-list" className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
| 231 |
{packages.map((pkg) => {
|
| 232 |
const isOwner = pkg.creatorUserId === userId;
|
|
|
|
| 233 |
return (
|
| 234 |
-
<Card
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 235 |
<CardContent className="p-5 flex flex-col h-full">
|
| 236 |
-
<
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 237 |
<div className="flex items-start justify-between mb-3">
|
| 238 |
<div className="flex gap-2 flex-wrap">
|
| 239 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 240 |
{pkg.examTypeName}
|
| 241 |
</span>
|
| 242 |
-
{isOwner && (
|
| 243 |
<span
|
| 244 |
className={`px-2 py-1 rounded-full text-[10px] font-semibold ${
|
| 245 |
pkg.isPublic
|
|
@@ -268,6 +381,7 @@ function PackagesComponent() {
|
|
| 268 |
{pkg.description}
|
| 269 |
</p>
|
| 270 |
)}
|
|
|
|
| 271 |
|
| 272 |
<div className="flex items-center justify-between mt-auto pt-3 border-t border-[var(--oat-border)]">
|
| 273 |
<div className="flex gap-3 text-xs text-[var(--warm-charcoal)]">
|
|
@@ -290,10 +404,10 @@ function PackagesComponent() {
|
|
| 290 |
{pkg.usageCount}x digunakan
|
| 291 |
</span>
|
| 292 |
</div>
|
| 293 |
-
</
|
| 294 |
|
| 295 |
{/* Owner actions */}
|
| 296 |
-
{isOwner && (
|
| 297 |
<div className="mt-3 pt-3 border-t border-[var(--oat-border)] flex items-center justify-between">
|
| 298 |
<button
|
| 299 |
onClick={() => togglePublic(pkg.id, pkg.isPublic)}
|
|
@@ -322,15 +436,18 @@ function PackagesComponent() {
|
|
| 322 |
</div>
|
| 323 |
)}
|
| 324 |
|
| 325 |
-
|
| 326 |
-
<
|
| 327 |
-
|
| 328 |
-
|
| 329 |
-
|
| 330 |
-
|
| 331 |
-
|
| 332 |
-
|
| 333 |
-
|
|
|
|
|
|
|
|
|
|
| 334 |
</CardContent>
|
| 335 |
</Card>
|
| 336 |
);
|
|
|
|
| 1 |
+
import { useState, useEffect } 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";
|
|
|
|
| 102 |
updateMutation.mutate({ id: pkgId, isPublic: !current });
|
| 103 |
};
|
| 104 |
|
| 105 |
+
const bulkPublish = useMutation({
|
| 106 |
+
...trpc.package.bulkPublish.mutationOptions(),
|
| 107 |
+
onSuccess: (data) => {
|
| 108 |
+
query.refetch();
|
| 109 |
+
setBulkMode(false);
|
| 110 |
+
setSelectedIds(new Set());
|
| 111 |
+
toast.success(`${data.updated} paket berhasil dipublikasikan`);
|
| 112 |
+
},
|
| 113 |
+
onError: (err: any) => toast.error("Gagal mempublikasikan", { description: err.message }),
|
| 114 |
+
});
|
| 115 |
+
|
| 116 |
+
// ── Bulk select ──
|
| 117 |
+
const [bulkMode, setBulkMode] = useState(false);
|
| 118 |
+
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
| 119 |
+
|
| 120 |
+
const toggleSelect = (id: string) => {
|
| 121 |
+
setSelectedIds((prev) => {
|
| 122 |
+
const next = new Set(prev);
|
| 123 |
+
if (next.has(id)) next.delete(id);
|
| 124 |
+
else next.add(id);
|
| 125 |
+
return next;
|
| 126 |
+
});
|
| 127 |
+
};
|
| 128 |
+
|
| 129 |
+
const clearSelection = () => setSelectedIds(new Set());
|
| 130 |
+
const selectAll = () => setSelectedIds(new Set(packages.map((p: any) => p.id)));
|
| 131 |
+
|
| 132 |
+
useEffect(() => {
|
| 133 |
+
setBulkMode(false);
|
| 134 |
+
setSelectedIds(new Set());
|
| 135 |
+
}, [tab, searchText, examType]);
|
| 136 |
+
|
| 137 |
const setTab = (newTab: Tab) => {
|
| 138 |
navigate({ search: { tab: newTab, search: "", examType: "", page: 1 } });
|
| 139 |
};
|
|
|
|
| 227 |
</Select>
|
| 228 |
</div>
|
| 229 |
|
| 230 |
+
{/* Bulk toolbar */}
|
| 231 |
+
{tab === "mine" && (
|
| 232 |
+
<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)]">
|
| 233 |
+
{bulkMode ? (
|
| 234 |
+
<>
|
| 235 |
+
<div className="flex items-center gap-3">
|
| 236 |
+
<button
|
| 237 |
+
onClick={clearSelection}
|
| 238 |
+
className="text-xs font-semibold text-[var(--warm-charcoal)] hover:text-[var(--clay-black)] transition-colors flex items-center gap-1"
|
| 239 |
+
>
|
| 240 |
+
<MaterialIcon name="close" className="text-xs" />
|
| 241 |
+
Batalkan ({selectedIds.size})
|
| 242 |
+
</button>
|
| 243 |
+
<button
|
| 244 |
+
onClick={selectAll}
|
| 245 |
+
className="text-xs font-semibold text-[var(--matcha-600)] hover:text-[var(--matcha-800)] transition-colors flex items-center gap-1"
|
| 246 |
+
>
|
| 247 |
+
<MaterialIcon name="select_all" className="text-xs" />
|
| 248 |
+
Pilih Semua
|
| 249 |
+
</button>
|
| 250 |
+
</div>
|
| 251 |
+
<div className="flex items-center gap-2">
|
| 252 |
+
<Button
|
| 253 |
+
size="lg"
|
| 254 |
+
disabled={selectedIds.size === 0 || bulkPublish.isPending}
|
| 255 |
+
onClick={() => bulkPublish.mutate({ ids: Array.from(selectedIds) })}
|
| 256 |
+
className="rounded-[var(--radius-md)] bg-[var(--matcha-600)] text-[var(--pure-white)] hover:bg-[var(--matcha-800)]"
|
| 257 |
+
>
|
| 258 |
+
<MaterialIcon name="public" className="text-xs mr-1" />
|
| 259 |
+
{bulkPublish.isPending ? "Mempublikasikan..." : `Jadikan Publik (${selectedIds.size})`}
|
| 260 |
+
</Button>
|
| 261 |
+
<button
|
| 262 |
+
onClick={() => { setBulkMode(false); setSelectedIds(new Set()); }}
|
| 263 |
+
className="text-xs font-semibold text-[var(--warm-charcoal)] hover:text-[var(--clay-black)] transition-colors"
|
| 264 |
+
>
|
| 265 |
+
Selesai
|
| 266 |
+
</button>
|
| 267 |
+
</div>
|
| 268 |
+
</>
|
| 269 |
+
) : (
|
| 270 |
+
<>
|
| 271 |
+
<span className="text-sm text-[var(--warm-charcoal)]">{packages.length} paket</span>
|
| 272 |
+
<button
|
| 273 |
+
onClick={() => setBulkMode(true)}
|
| 274 |
+
className="text-xs font-semibold text-[var(--matcha-600)] hover:text-[var(--matcha-800)] transition-colors flex items-center gap-1"
|
| 275 |
+
>
|
| 276 |
+
<MaterialIcon name="select_all" className="text-sm" />
|
| 277 |
+
Pilih Banyak
|
| 278 |
+
</button>
|
| 279 |
+
</>
|
| 280 |
+
)}
|
| 281 |
+
</div>
|
| 282 |
+
)}
|
| 283 |
+
|
| 284 |
{/* Results */}
|
| 285 |
{query.isLoading ? (
|
| 286 |
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
|
|
|
| 317 |
<div data-tour="packages-list" className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
| 318 |
{packages.map((pkg) => {
|
| 319 |
const isOwner = pkg.creatorUserId === userId;
|
| 320 |
+
const isSelected = selectedIds.has(pkg.id);
|
| 321 |
return (
|
| 322 |
+
<Card
|
| 323 |
+
key={pkg.id}
|
| 324 |
+
className={`clay-shadow clay-hover bg-[var(--pure-white)] border-2 rounded-[var(--radius-xl)] h-full flex flex-col ${
|
| 325 |
+
bulkMode && isSelected
|
| 326 |
+
? "border-[var(--matcha-600)] ring-2 ring-[var(--matcha-400)]"
|
| 327 |
+
: "border-[var(--oat-border)]"
|
| 328 |
+
}`}
|
| 329 |
+
>
|
| 330 |
<CardContent className="p-5 flex flex-col h-full">
|
| 331 |
+
<div
|
| 332 |
+
className="block flex-1 cursor-pointer"
|
| 333 |
+
onClick={bulkMode ? () => toggleSelect(pkg.id) : undefined}
|
| 334 |
+
>
|
| 335 |
+
<Link
|
| 336 |
+
to="/package/$id"
|
| 337 |
+
params={{ id: pkg.id }}
|
| 338 |
+
className={bulkMode ? "pointer-events-none" : ""}
|
| 339 |
+
>
|
| 340 |
<div className="flex items-start justify-between mb-3">
|
| 341 |
<div className="flex gap-2 flex-wrap">
|
| 342 |
+
{bulkMode && (
|
| 343 |
+
<span className={`px-2 py-1 rounded-full text-[10px] font-semibold flex items-center gap-1 ${
|
| 344 |
+
isSelected
|
| 345 |
+
? "bg-[var(--matcha-600)] text-[var(--pure-white)]"
|
| 346 |
+
: "bg-[var(--oat-light)] text-[var(--warm-charcoal)]"
|
| 347 |
+
}`}>
|
| 348 |
+
<MaterialIcon name={isSelected ? "check_circle" : "radio_button_unchecked"} className="text-xs" />
|
| 349 |
+
{isSelected ? "Terpilih" : "Pilih"}
|
| 350 |
+
</span>
|
| 351 |
+
)}
|
| 352 |
+
<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">
|
| 353 |
{pkg.examTypeName}
|
| 354 |
</span>
|
| 355 |
+
{isOwner && !bulkMode && (
|
| 356 |
<span
|
| 357 |
className={`px-2 py-1 rounded-full text-[10px] font-semibold ${
|
| 358 |
pkg.isPublic
|
|
|
|
| 381 |
{pkg.description}
|
| 382 |
</p>
|
| 383 |
)}
|
| 384 |
+
</Link>
|
| 385 |
|
| 386 |
<div className="flex items-center justify-between mt-auto pt-3 border-t border-[var(--oat-border)]">
|
| 387 |
<div className="flex gap-3 text-xs text-[var(--warm-charcoal)]">
|
|
|
|
| 404 |
{pkg.usageCount}x digunakan
|
| 405 |
</span>
|
| 406 |
</div>
|
| 407 |
+
</div>
|
| 408 |
|
| 409 |
{/* Owner actions */}
|
| 410 |
+
{isOwner && !bulkMode && (
|
| 411 |
<div className="mt-3 pt-3 border-t border-[var(--oat-border)] flex items-center justify-between">
|
| 412 |
<button
|
| 413 |
onClick={() => togglePublic(pkg.id, pkg.isPublic)}
|
|
|
|
| 436 |
</div>
|
| 437 |
)}
|
| 438 |
|
| 439 |
+
{!bulkMode && (
|
| 440 |
+
<div className="mt-3 pt-3 border-t border-[var(--oat-border)]">
|
| 441 |
+
<Button
|
| 442 |
+
className="w-full bg-[var(--matcha-600)] text-[var(--pure-white)] hover:bg-[var(--matcha-800)] clay-hover rounded-[var(--radius-lg)]"
|
| 443 |
+
onClick={() => routerNavigate({ to: '/package/$id/take', params: { id: pkg.id } })}
|
| 444 |
+
size="xl"
|
| 445 |
+
>
|
| 446 |
+
<MaterialIcon name="play_arrow" className="mr-2" />
|
| 447 |
+
Mulai Latihan
|
| 448 |
+
</Button>
|
| 449 |
+
</div>
|
| 450 |
+
)}
|
| 451 |
</CardContent>
|
| 452 |
</Card>
|
| 453 |
);
|
apps/web/src/routes/settings.tsx
CHANGED
|
@@ -17,12 +17,13 @@ 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 { z } from "zod";
|
| 21 |
|
| 22 |
export const Route = createFileRoute("/settings")({
|
| 23 |
component: RouteComponent,
|
| 24 |
validateSearch: z.object({
|
| 25 |
-
tab: z.enum(["api-keys", "token-usage", "security"]).optional(),
|
| 26 |
}).parse,
|
| 27 |
beforeLoad: async () => {
|
| 28 |
const session = await authClient.getSession();
|
|
@@ -52,7 +53,7 @@ function defaultConfig(): Omit<ApiKeyConfig, "id" | "apiKey"> {
|
|
| 52 |
};
|
| 53 |
}
|
| 54 |
|
| 55 |
-
type Tab = "api-keys" | "token-usage" | "security";
|
| 56 |
|
| 57 |
function TokenUsageSection() {
|
| 58 |
const { data, isLoading } = useQuery(trpc.ai.tokenUsageToday.queryOptions());
|
|
@@ -296,6 +297,7 @@ function RouteComponent() {
|
|
| 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="security">Keamanan</TabsTrigger>
|
| 300 |
</TabsList>
|
| 301 |
|
|
@@ -337,6 +339,10 @@ function RouteComponent() {
|
|
| 337 |
<TokenUsageSection />
|
| 338 |
</TabsContent>
|
| 339 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 340 |
<TabsContent value="security">
|
| 341 |
<SecurityInfo />
|
| 342 |
</TabsContent>
|
|
|
|
| 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,
|
| 28 |
beforeLoad: async () => {
|
| 29 |
const session = await authClient.getSession();
|
|
|
|
| 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());
|
|
|
|
| 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 |
|
|
|
|
| 339 |
<TokenUsageSection />
|
| 340 |
</TabsContent>
|
| 341 |
|
| 342 |
+
<TabsContent value="account">
|
| 343 |
+
<AccountSettings />
|
| 344 |
+
</TabsContent>
|
| 345 |
+
|
| 346 |
<TabsContent value="security">
|
| 347 |
<SecurityInfo />
|
| 348 |
</TabsContent>
|
apps/web/src/routes/verify-email.tsx
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { createFileRoute, Link, useNavigate, redirect } from "@tanstack/react-router";
|
| 2 |
+
import { useState, useEffect, useRef } from "react";
|
| 3 |
+
import { useMutation } from "@tanstack/react-query";
|
| 4 |
+
import { Button } from "@labas/ui/components/button";
|
| 5 |
+
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@labas/ui/components/card";
|
| 6 |
+
import { Input } from "@labas/ui/components/input";
|
| 7 |
+
import { toast } from "sonner";
|
| 8 |
+
import { authClient } from "@/lib/auth-client";
|
| 9 |
+
import { trpc } from "@/utils/trpc";
|
| 10 |
+
import { z } from "zod";
|
| 11 |
+
|
| 12 |
+
const searchSchema = z.object({
|
| 13 |
+
email: z.string().optional(),
|
| 14 |
+
});
|
| 15 |
+
|
| 16 |
+
export const Route = createFileRoute("/verify-email")({
|
| 17 |
+
component: RouteComponent,
|
| 18 |
+
validateSearch: searchSchema,
|
| 19 |
+
beforeLoad: async ({ search }) => {
|
| 20 |
+
if (!search.email) {
|
| 21 |
+
const session = await authClient.getSession();
|
| 22 |
+
if (!session.data) {
|
| 23 |
+
redirect({ to: "/login", throw: true });
|
| 24 |
+
}
|
| 25 |
+
}
|
| 26 |
+
},
|
| 27 |
+
});
|
| 28 |
+
|
| 29 |
+
function RouteComponent() {
|
| 30 |
+
const navigate = useNavigate();
|
| 31 |
+
const { email: searchEmail } = Route.useSearch();
|
| 32 |
+
const [sessionEmail, setSessionEmail] = useState<string | null>(null);
|
| 33 |
+
const email = searchEmail || sessionEmail || "";
|
| 34 |
+
const [otp, setOtp] = useState(["", "", "", "", "", ""]);
|
| 35 |
+
const [countdown, setCountdown] = useState(0);
|
| 36 |
+
const inputRefs = useRef<(HTMLInputElement | null)[]>([]);
|
| 37 |
+
|
| 38 |
+
const sendMutation = useMutation(trpc.verification.sendVerificationOtp.mutationOptions());
|
| 39 |
+
const verifyMutation = useMutation(trpc.verification.verifyEmailOtp.mutationOptions());
|
| 40 |
+
|
| 41 |
+
useEffect(() => {
|
| 42 |
+
if (!searchEmail) {
|
| 43 |
+
authClient.getSession().then((s) => {
|
| 44 |
+
if (s.data?.user.email) setSessionEmail(s.data.user.email);
|
| 45 |
+
});
|
| 46 |
+
}
|
| 47 |
+
}, [searchEmail]);
|
| 48 |
+
|
| 49 |
+
useEffect(() => {
|
| 50 |
+
if (countdown > 0) {
|
| 51 |
+
const timer = setTimeout(() => setCountdown(countdown - 1), 1000);
|
| 52 |
+
return () => clearTimeout(timer);
|
| 53 |
+
}
|
| 54 |
+
}, [countdown]);
|
| 55 |
+
|
| 56 |
+
useEffect(() => {
|
| 57 |
+
if (email) {
|
| 58 |
+
handleSendOtp();
|
| 59 |
+
}
|
| 60 |
+
}, [email]);
|
| 61 |
+
|
| 62 |
+
useEffect(() => {
|
| 63 |
+
inputRefs.current[0]?.focus();
|
| 64 |
+
}, []);
|
| 65 |
+
|
| 66 |
+
const handleSendOtp = async () => {
|
| 67 |
+
if (!email) return;
|
| 68 |
+
try {
|
| 69 |
+
await sendMutation.mutateAsync({ email });
|
| 70 |
+
toast.success("Kode OTP telah dikirim ke email Anda");
|
| 71 |
+
setCountdown(60);
|
| 72 |
+
} catch (err: any) {
|
| 73 |
+
toast.error(err.message || "Gagal mengirim OTP");
|
| 74 |
+
}
|
| 75 |
+
};
|
| 76 |
+
|
| 77 |
+
const handleOtpChange = (index: number, value: string) => {
|
| 78 |
+
if (!/^\d?$/.test(value)) return;
|
| 79 |
+
const newOtp = [...otp];
|
| 80 |
+
newOtp[index] = value;
|
| 81 |
+
setOtp(newOtp);
|
| 82 |
+
if (value && index < 5) {
|
| 83 |
+
inputRefs.current[index + 1]?.focus();
|
| 84 |
+
}
|
| 85 |
+
};
|
| 86 |
+
|
| 87 |
+
const handleKeyDown = (index: number, e: React.KeyboardEvent) => {
|
| 88 |
+
if (e.key === "Backspace" && !otp[index] && index > 0) {
|
| 89 |
+
inputRefs.current[index - 1]?.focus();
|
| 90 |
+
}
|
| 91 |
+
};
|
| 92 |
+
|
| 93 |
+
const handleVerify = async () => {
|
| 94 |
+
const code = otp.join("");
|
| 95 |
+
if (code.length !== 6) {
|
| 96 |
+
toast.error("Masukkan 6 digit kode OTP");
|
| 97 |
+
return;
|
| 98 |
+
}
|
| 99 |
+
try {
|
| 100 |
+
await verifyMutation.mutateAsync({ email, otp: code });
|
| 101 |
+
toast.success("Email berhasil diverifikasi!");
|
| 102 |
+
const session = await authClient.getSession();
|
| 103 |
+
if (session.data) {
|
| 104 |
+
navigate({ to: "/" });
|
| 105 |
+
} else {
|
| 106 |
+
navigate({ to: "/login" });
|
| 107 |
+
}
|
| 108 |
+
} catch (err: any) {
|
| 109 |
+
toast.error(err.message || "Kode OTP tidak valid");
|
| 110 |
+
setOtp(["", "", "", "", "", ""]);
|
| 111 |
+
inputRefs.current[0]?.focus();
|
| 112 |
+
}
|
| 113 |
+
};
|
| 114 |
+
|
| 115 |
+
const handlePaste = (e: React.ClipboardEvent) => {
|
| 116 |
+
e.preventDefault();
|
| 117 |
+
const pasted = e.clipboardData.getData("text").replace(/\D/g, "").slice(0, 6);
|
| 118 |
+
const newOtp = [...otp];
|
| 119 |
+
for (let i = 0; i < pasted.length; i++) {
|
| 120 |
+
newOtp[i] = pasted[i];
|
| 121 |
+
}
|
| 122 |
+
setOtp(newOtp);
|
| 123 |
+
const nextIndex = Math.min(pasted.length, 5);
|
| 124 |
+
inputRefs.current[nextIndex]?.focus();
|
| 125 |
+
};
|
| 126 |
+
|
| 127 |
+
if (!email) {
|
| 128 |
+
return (
|
| 129 |
+
<div className="flex h-screen w-full items-center justify-center bg-muted/30">
|
| 130 |
+
<p className="text-muted-foreground">Memuat...</p>
|
| 131 |
+
</div>
|
| 132 |
+
);
|
| 133 |
+
}
|
| 134 |
+
|
| 135 |
+
const isMutating = sendMutation.isPending || verifyMutation.isPending;
|
| 136 |
+
|
| 137 |
+
return (
|
| 138 |
+
<div className="flex h-screen w-full items-center justify-center bg-muted/30">
|
| 139 |
+
<Card className="mx-auto w-full max-w-md shadow-lg border-muted rounded-xl">
|
| 140 |
+
<CardHeader className="space-y-1 text-center">
|
| 141 |
+
<CardTitle className="text-3xl font-bold tracking-tight">Verifikasi Email</CardTitle>
|
| 142 |
+
<CardDescription>
|
| 143 |
+
Masukkan kode 6 digit yang dikirim ke <strong>{email}</strong>
|
| 144 |
+
</CardDescription>
|
| 145 |
+
</CardHeader>
|
| 146 |
+
<CardContent className="space-y-6">
|
| 147 |
+
<div className="flex justify-center gap-2" onPaste={handlePaste}>
|
| 148 |
+
{otp.map((digit, i) => (
|
| 149 |
+
<Input
|
| 150 |
+
key={i}
|
| 151 |
+
ref={(el) => { inputRefs.current[i] = el; }}
|
| 152 |
+
type="text"
|
| 153 |
+
inputMode="numeric"
|
| 154 |
+
maxLength={1}
|
| 155 |
+
value={digit}
|
| 156 |
+
onChange={(e) => handleOtpChange(i, e.target.value)}
|
| 157 |
+
onKeyDown={(e) => handleKeyDown(i, e)}
|
| 158 |
+
className="w-12 h-14 text-center text-xl font-bold"
|
| 159 |
+
/>
|
| 160 |
+
))}
|
| 161 |
+
</div>
|
| 162 |
+
|
| 163 |
+
<Button
|
| 164 |
+
type="button"
|
| 165 |
+
className="w-full"
|
| 166 |
+
onClick={handleVerify}
|
| 167 |
+
disabled={isMutating || otp.join("").length !== 6}
|
| 168 |
+
>
|
| 169 |
+
{verifyMutation.isPending ? "Memverifikasi..." : "Verifikasi Email"}
|
| 170 |
+
</Button>
|
| 171 |
+
|
| 172 |
+
<div className="text-center text-sm text-muted-foreground">
|
| 173 |
+
Tidak menerima kode?{" "}
|
| 174 |
+
<Button
|
| 175 |
+
variant="link"
|
| 176 |
+
className="p-0 h-auto font-semibold"
|
| 177 |
+
disabled={sendMutation.isPending || countdown > 0}
|
| 178 |
+
onClick={handleSendOtp}
|
| 179 |
+
>
|
| 180 |
+
{countdown > 0 ? `Kirim ulang (${countdown}s)` : "Kirim Ulang"}
|
| 181 |
+
</Button>
|
| 182 |
+
</div>
|
| 183 |
+
|
| 184 |
+
<div className="text-center">
|
| 185 |
+
<Link to="/login" className="text-sm text-muted-foreground hover:text-primary">
|
| 186 |
+
Kembali ke halaman masuk
|
| 187 |
+
</Link>
|
| 188 |
+
</div>
|
| 189 |
+
</CardContent>
|
| 190 |
+
</Card>
|
| 191 |
+
</div>
|
| 192 |
+
);
|
| 193 |
+
}
|
bun.lock
CHANGED
|
@@ -29,11 +29,13 @@
|
|
| 29 |
"better-auth": "catalog:",
|
| 30 |
"dotenv": "catalog:",
|
| 31 |
"hono": "catalog:",
|
|
|
|
| 32 |
"zod": "catalog:",
|
| 33 |
},
|
| 34 |
"devDependencies": {
|
| 35 |
"@labas/config": "workspace:*",
|
| 36 |
"@types/bun": "catalog:",
|
|
|
|
| 37 |
"tsdown": "^0.21.9",
|
| 38 |
"typescript": "catalog:",
|
| 39 |
},
|
|
@@ -62,6 +64,7 @@
|
|
| 62 |
"next-themes": "catalog:",
|
| 63 |
"react": "^19.2.5",
|
| 64 |
"react-dom": "^19.2.5",
|
|
|
|
| 65 |
"recharts": "^3.8.1",
|
| 66 |
"sonner": "^2.0.7",
|
| 67 |
"vite-plugin-pwa": "^1.2.0",
|
|
@@ -103,15 +106,19 @@
|
|
| 103 |
"@labas/env": "workspace:*",
|
| 104 |
"@trpc/client": "catalog:",
|
| 105 |
"@trpc/server": "catalog:",
|
|
|
|
| 106 |
"bullmq": "^5.76.2",
|
| 107 |
"dotenv": "catalog:",
|
| 108 |
"drizzle-orm": "^0.45.1",
|
| 109 |
"ioredis": "^5.10.1",
|
|
|
|
| 110 |
"winston": "^3.19.0",
|
| 111 |
"zod": "catalog:",
|
| 112 |
},
|
| 113 |
"devDependencies": {
|
| 114 |
"@labas/config": "workspace:*",
|
|
|
|
|
|
|
| 115 |
"hono": "catalog:",
|
| 116 |
"typescript": "catalog:",
|
| 117 |
},
|
|
@@ -780,6 +787,8 @@
|
|
| 780 |
|
| 781 |
"@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="],
|
| 782 |
|
|
|
|
|
|
|
| 783 |
"@types/bun": ["@types/bun@1.3.13", "", { "dependencies": { "bun-types": "1.3.13" } }, "sha512-9fqXWk5YIHGGnUau9TEi+qdlTYDAnOj+xLCmSTwXfAIqXr2x4tytJb43E9uCvt09zJURKXwAtkoH4nLQfzeTXw=="],
|
| 784 |
|
| 785 |
"@types/d3-array": ["@types/d3-array@3.2.2", "", {}, "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw=="],
|
|
@@ -806,6 +815,8 @@
|
|
| 806 |
|
| 807 |
"@types/node": ["@types/node@22.19.17", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q=="],
|
| 808 |
|
|
|
|
|
|
|
| 809 |
"@types/pg": ["@types/pg@8.20.0", "", { "dependencies": { "@types/node": "*", "pg-protocol": "*", "pg-types": "^2.2.0" } }, "sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow=="],
|
| 810 |
|
| 811 |
"@types/react": ["@types/react@19.2.14", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w=="],
|
|
@@ -882,6 +893,8 @@
|
|
| 882 |
|
| 883 |
"baseline-browser-mapping": ["baseline-browser-mapping@2.10.21", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-Q+rUQ7Uz8AHM7DEaNdwvfFCTq7a43lNTzuS94eiWqwyxfV/wJv+oUivef51T91mmRY4d4A1u9rcSvkeufCVXlA=="],
|
| 884 |
|
|
|
|
|
|
|
| 885 |
"better-auth": ["better-auth@1.5.5", "", { "dependencies": { "@better-auth/core": "1.5.5", "@better-auth/drizzle-adapter": "1.5.5", "@better-auth/kysely-adapter": "1.5.5", "@better-auth/memory-adapter": "1.5.5", "@better-auth/mongo-adapter": "1.5.5", "@better-auth/prisma-adapter": "1.5.5", "@better-auth/telemetry": "1.5.5", "@better-auth/utils": "0.3.1", "@better-fetch/fetch": "1.1.21", "@noble/ciphers": "^2.1.1", "@noble/hashes": "^2.0.1", "better-call": "1.3.2", "defu": "^6.1.4", "jose": "^6.1.3", "kysely": "^0.28.11", "nanostores": "^1.1.1", "zod": "^4.3.6" }, "peerDependencies": { "@lynx-js/react": "*", "@prisma/client": "^5.0.0 || ^6.0.0 || ^7.0.0", "@sveltejs/kit": "^2.0.0", "@tanstack/react-start": "^1.0.0", "@tanstack/solid-start": "^1.0.0", "better-sqlite3": "^12.0.0", "drizzle-kit": ">=0.31.4", "drizzle-orm": ">=0.41.0", "mongodb": "^6.0.0 || ^7.0.0", "mysql2": "^3.0.0", "next": "^14.0.0 || ^15.0.0 || ^16.0.0", "pg": "^8.0.0", "prisma": "^5.0.0 || ^6.0.0 || ^7.0.0", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0", "solid-js": "^1.0.0", "svelte": "^4.0.0 || ^5.0.0", "vitest": "^2.0.0 || ^3.0.0 || ^4.0.0", "vue": "^3.0.0" }, "optionalPeers": ["@lynx-js/react", "@prisma/client", "@sveltejs/kit", "@tanstack/react-start", "@tanstack/solid-start", "better-sqlite3", "drizzle-kit", "drizzle-orm", "mongodb", "mysql2", "next", "pg", "prisma", "react", "react-dom", "solid-js", "svelte", "vitest", "vue"] }, "sha512-GpVPaV1eqr3mOovKfghJXXk6QvlcVeFbS3z+n+FPDid5rK/2PchnDtiaVCzWyXA9jH2KkirOfl+JhAUvnja0Eg=="],
|
| 886 |
|
| 887 |
"better-call": ["better-call@1.3.2", "", { "dependencies": { "@better-auth/utils": "^0.3.1", "@better-fetch/fetch": "^1.1.21", "rou3": "^0.7.12", "set-cookie-parser": "^3.0.1" }, "peerDependencies": { "zod": "^4.0.0" }, "optionalPeers": ["zod"] }, "sha512-4cZIfrerDsNTn3cm+MhLbUePN0gdwkhSXEuG7r/zuQ8c/H7iU0/jSK5TD3FW7U0MgKHce/8jGpPYNO4Ve+4NBw=="],
|
|
@@ -1496,6 +1509,8 @@
|
|
| 1496 |
|
| 1497 |
"node-releases": ["node-releases@2.0.38", "", {}, "sha512-3qT/88Y3FbH/Kx4szpQQ4HzUbVrHPKTLVpVocKiLfoYvw9XSGOX2FmD2d6DrXbVYyAQTF2HeF6My8jmzx7/CRw=="],
|
| 1498 |
|
|
|
|
|
|
|
| 1499 |
"normalize-path": ["normalize-path@3.0.0", "", {}, "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA=="],
|
| 1500 |
|
| 1501 |
"npm-run-path": ["npm-run-path@6.0.0", "", { "dependencies": { "path-key": "^4.0.0", "unicorn-magic": "^0.3.0" } }, "sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA=="],
|
|
|
|
| 29 |
"better-auth": "catalog:",
|
| 30 |
"dotenv": "catalog:",
|
| 31 |
"hono": "catalog:",
|
| 32 |
+
"nodemailer": "^8.0.7",
|
| 33 |
"zod": "catalog:",
|
| 34 |
},
|
| 35 |
"devDependencies": {
|
| 36 |
"@labas/config": "workspace:*",
|
| 37 |
"@types/bun": "catalog:",
|
| 38 |
+
"@types/nodemailer": "^8.0.0",
|
| 39 |
"tsdown": "^0.21.9",
|
| 40 |
"typescript": "catalog:",
|
| 41 |
},
|
|
|
|
| 64 |
"next-themes": "catalog:",
|
| 65 |
"react": "^19.2.5",
|
| 66 |
"react-dom": "^19.2.5",
|
| 67 |
+
"react-joyride": "^3.1.0",
|
| 68 |
"recharts": "^3.8.1",
|
| 69 |
"sonner": "^2.0.7",
|
| 70 |
"vite-plugin-pwa": "^1.2.0",
|
|
|
|
| 106 |
"@labas/env": "workspace:*",
|
| 107 |
"@trpc/client": "catalog:",
|
| 108 |
"@trpc/server": "catalog:",
|
| 109 |
+
"bcryptjs": "^3.0.3",
|
| 110 |
"bullmq": "^5.76.2",
|
| 111 |
"dotenv": "catalog:",
|
| 112 |
"drizzle-orm": "^0.45.1",
|
| 113 |
"ioredis": "^5.10.1",
|
| 114 |
+
"nodemailer": "^8.0.7",
|
| 115 |
"winston": "^3.19.0",
|
| 116 |
"zod": "catalog:",
|
| 117 |
},
|
| 118 |
"devDependencies": {
|
| 119 |
"@labas/config": "workspace:*",
|
| 120 |
+
"@types/bcryptjs": "^3.0.0",
|
| 121 |
+
"@types/nodemailer": "^8.0.0",
|
| 122 |
"hono": "catalog:",
|
| 123 |
"typescript": "catalog:",
|
| 124 |
},
|
|
|
|
| 787 |
|
| 788 |
"@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="],
|
| 789 |
|
| 790 |
+
"@types/bcryptjs": ["@types/bcryptjs@3.0.0", "", { "dependencies": { "bcryptjs": "*" } }, "sha512-WRZOuCuaz8UcZZE4R5HXTco2goQSI2XxjGY3hbM/xDvwmqFWd4ivooImsMx65OKM6CtNKbnZ5YL+YwAwK7c1dg=="],
|
| 791 |
+
|
| 792 |
"@types/bun": ["@types/bun@1.3.13", "", { "dependencies": { "bun-types": "1.3.13" } }, "sha512-9fqXWk5YIHGGnUau9TEi+qdlTYDAnOj+xLCmSTwXfAIqXr2x4tytJb43E9uCvt09zJURKXwAtkoH4nLQfzeTXw=="],
|
| 793 |
|
| 794 |
"@types/d3-array": ["@types/d3-array@3.2.2", "", {}, "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw=="],
|
|
|
|
| 815 |
|
| 816 |
"@types/node": ["@types/node@22.19.17", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q=="],
|
| 817 |
|
| 818 |
+
"@types/nodemailer": ["@types/nodemailer@8.0.0", "", { "dependencies": { "@types/node": "*" } }, "sha512-fyf8jWULsCo0d0BuoQ75i6IeoHs47qcqxWc7yUdUcV0pOZGjUTTOvwdG1PRXUDqN/8A64yQdQdnA2pZgcdi+cA=="],
|
| 819 |
+
|
| 820 |
"@types/pg": ["@types/pg@8.20.0", "", { "dependencies": { "@types/node": "*", "pg-protocol": "*", "pg-types": "^2.2.0" } }, "sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow=="],
|
| 821 |
|
| 822 |
"@types/react": ["@types/react@19.2.14", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w=="],
|
|
|
|
| 893 |
|
| 894 |
"baseline-browser-mapping": ["baseline-browser-mapping@2.10.21", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-Q+rUQ7Uz8AHM7DEaNdwvfFCTq7a43lNTzuS94eiWqwyxfV/wJv+oUivef51T91mmRY4d4A1u9rcSvkeufCVXlA=="],
|
| 895 |
|
| 896 |
+
"bcryptjs": ["bcryptjs@3.0.3", "", { "bin": { "bcrypt": "bin/bcrypt" } }, "sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g=="],
|
| 897 |
+
|
| 898 |
"better-auth": ["better-auth@1.5.5", "", { "dependencies": { "@better-auth/core": "1.5.5", "@better-auth/drizzle-adapter": "1.5.5", "@better-auth/kysely-adapter": "1.5.5", "@better-auth/memory-adapter": "1.5.5", "@better-auth/mongo-adapter": "1.5.5", "@better-auth/prisma-adapter": "1.5.5", "@better-auth/telemetry": "1.5.5", "@better-auth/utils": "0.3.1", "@better-fetch/fetch": "1.1.21", "@noble/ciphers": "^2.1.1", "@noble/hashes": "^2.0.1", "better-call": "1.3.2", "defu": "^6.1.4", "jose": "^6.1.3", "kysely": "^0.28.11", "nanostores": "^1.1.1", "zod": "^4.3.6" }, "peerDependencies": { "@lynx-js/react": "*", "@prisma/client": "^5.0.0 || ^6.0.0 || ^7.0.0", "@sveltejs/kit": "^2.0.0", "@tanstack/react-start": "^1.0.0", "@tanstack/solid-start": "^1.0.0", "better-sqlite3": "^12.0.0", "drizzle-kit": ">=0.31.4", "drizzle-orm": ">=0.41.0", "mongodb": "^6.0.0 || ^7.0.0", "mysql2": "^3.0.0", "next": "^14.0.0 || ^15.0.0 || ^16.0.0", "pg": "^8.0.0", "prisma": "^5.0.0 || ^6.0.0 || ^7.0.0", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0", "solid-js": "^1.0.0", "svelte": "^4.0.0 || ^5.0.0", "vitest": "^2.0.0 || ^3.0.0 || ^4.0.0", "vue": "^3.0.0" }, "optionalPeers": ["@lynx-js/react", "@prisma/client", "@sveltejs/kit", "@tanstack/react-start", "@tanstack/solid-start", "better-sqlite3", "drizzle-kit", "drizzle-orm", "mongodb", "mysql2", "next", "pg", "prisma", "react", "react-dom", "solid-js", "svelte", "vitest", "vue"] }, "sha512-GpVPaV1eqr3mOovKfghJXXk6QvlcVeFbS3z+n+FPDid5rK/2PchnDtiaVCzWyXA9jH2KkirOfl+JhAUvnja0Eg=="],
|
| 899 |
|
| 900 |
"better-call": ["better-call@1.3.2", "", { "dependencies": { "@better-auth/utils": "^0.3.1", "@better-fetch/fetch": "^1.1.21", "rou3": "^0.7.12", "set-cookie-parser": "^3.0.1" }, "peerDependencies": { "zod": "^4.0.0" }, "optionalPeers": ["zod"] }, "sha512-4cZIfrerDsNTn3cm+MhLbUePN0gdwkhSXEuG7r/zuQ8c/H7iU0/jSK5TD3FW7U0MgKHce/8jGpPYNO4Ve+4NBw=="],
|
|
|
|
| 1509 |
|
| 1510 |
"node-releases": ["node-releases@2.0.38", "", {}, "sha512-3qT/88Y3FbH/Kx4szpQQ4HzUbVrHPKTLVpVocKiLfoYvw9XSGOX2FmD2d6DrXbVYyAQTF2HeF6My8jmzx7/CRw=="],
|
| 1511 |
|
| 1512 |
+
"nodemailer": ["nodemailer@8.0.7", "", {}, "sha512-pkjE4mkBzQjdJT4/UmlKl3pX0rC9fZmjh7c6C9o7lv66Ac6w9WCnzPzhbPNxwZAzlF4mdq4CSWB5+FbK6FWCow=="],
|
| 1513 |
+
|
| 1514 |
"normalize-path": ["normalize-path@3.0.0", "", {}, "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA=="],
|
| 1515 |
|
| 1516 |
"npm-run-path": ["npm-run-path@6.0.0", "", { "dependencies": { "path-key": "^4.0.0", "unicorn-magic": "^0.3.0" } }, "sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA=="],
|
packages/api/package.json
CHANGED
|
@@ -18,15 +18,19 @@
|
|
| 18 |
"@labas/env": "workspace:*",
|
| 19 |
"@trpc/client": "catalog:",
|
| 20 |
"@trpc/server": "catalog:",
|
|
|
|
| 21 |
"bullmq": "^5.76.2",
|
| 22 |
"dotenv": "catalog:",
|
| 23 |
"drizzle-orm": "^0.45.1",
|
| 24 |
"ioredis": "^5.10.1",
|
|
|
|
| 25 |
"winston": "^3.19.0",
|
| 26 |
"zod": "catalog:"
|
| 27 |
},
|
| 28 |
"devDependencies": {
|
| 29 |
"@labas/config": "workspace:*",
|
|
|
|
|
|
|
| 30 |
"hono": "catalog:",
|
| 31 |
"typescript": "catalog:"
|
| 32 |
}
|
|
|
|
| 18 |
"@labas/env": "workspace:*",
|
| 19 |
"@trpc/client": "catalog:",
|
| 20 |
"@trpc/server": "catalog:",
|
| 21 |
+
"bcryptjs": "^3.0.3",
|
| 22 |
"bullmq": "^5.76.2",
|
| 23 |
"dotenv": "catalog:",
|
| 24 |
"drizzle-orm": "^0.45.1",
|
| 25 |
"ioredis": "^5.10.1",
|
| 26 |
+
"nodemailer": "^8.0.7",
|
| 27 |
"winston": "^3.19.0",
|
| 28 |
"zod": "catalog:"
|
| 29 |
},
|
| 30 |
"devDependencies": {
|
| 31 |
"@labas/config": "workspace:*",
|
| 32 |
+
"@types/bcryptjs": "^3.0.0",
|
| 33 |
+
"@types/nodemailer": "^8.0.0",
|
| 34 |
"hono": "catalog:",
|
| 35 |
"typescript": "catalog:"
|
| 36 |
}
|
packages/api/src/lib/email.ts
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { env } from "@labas/env/server";
|
| 2 |
+
import nodemailer from "nodemailer";
|
| 3 |
+
|
| 4 |
+
const smtpPort = Number(env.SMTP_PORT) || 587;
|
| 5 |
+
const transporter = nodemailer.createTransport({
|
| 6 |
+
host: env.SMTP_HOST,
|
| 7 |
+
port: smtpPort,
|
| 8 |
+
secure: smtpPort === 465,
|
| 9 |
+
auth: {
|
| 10 |
+
user: env.SMTP_USER,
|
| 11 |
+
pass: env.SMTP_PASS,
|
| 12 |
+
},
|
| 13 |
+
});
|
| 14 |
+
|
| 15 |
+
const subjects: Record<string, string> = {
|
| 16 |
+
"email-verification": "Verifikasi Email Labas",
|
| 17 |
+
"forget-password": "Reset Password Labas",
|
| 18 |
+
};
|
| 19 |
+
|
| 20 |
+
const messages: Record<string, string> = {
|
| 21 |
+
"email-verification": "Gunakan kode berikut untuk memverifikasi email Anda:",
|
| 22 |
+
"forget-password": "Gunakan kode berikut untuk mereset password Anda:",
|
| 23 |
+
};
|
| 24 |
+
|
| 25 |
+
type SendOtpEmailProps = {
|
| 26 |
+
to: string;
|
| 27 |
+
otp: string;
|
| 28 |
+
type: "email-verification" | "forget-password";
|
| 29 |
+
};
|
| 30 |
+
|
| 31 |
+
export async function sendOtpEmail({ to, otp, type }: SendOtpEmailProps) {
|
| 32 |
+
const subject = subjects[type] || "Kode Verifikasi Labas";
|
| 33 |
+
const message = messages[type] || "Kode verifikasi Anda:";
|
| 34 |
+
|
| 35 |
+
await transporter.sendMail({
|
| 36 |
+
from: env.SMTP_FROM,
|
| 37 |
+
to,
|
| 38 |
+
subject,
|
| 39 |
+
html: `
|
| 40 |
+
<div style="font-family: sans-serif; max-width: 400px; margin: 0 auto; padding: 20px;">
|
| 41 |
+
<h2 style="color: #16a34a;">Labas</h2>
|
| 42 |
+
<p>${message}</p>
|
| 43 |
+
<div style="background: #f3f4f6; padding: 20px; border-radius: 8px; text-align: center; margin: 20px 0;">
|
| 44 |
+
<span style="font-size: 32px; font-weight: bold; letter-spacing: 8px; color: #111827;">${otp}</span>
|
| 45 |
+
</div>
|
| 46 |
+
<p style="color: #6b7280; font-size: 14px;">Kode berlaku selama 5 menit. Jangan bagikan kode ini kepada siapapun.</p>
|
| 47 |
+
</div>
|
| 48 |
+
`,
|
| 49 |
+
});
|
| 50 |
+
}
|
packages/api/src/routers/attempt.ts
CHANGED
|
@@ -184,7 +184,9 @@ export const attemptRouter = router({
|
|
| 184 |
.where(eq(testPackage.id, input.packageId))
|
| 185 |
.limit(1);
|
| 186 |
|
| 187 |
-
|
|
|
|
|
|
|
| 188 |
|
| 189 |
const sections = await db
|
| 190 |
.select({
|
|
|
|
| 184 |
.where(eq(testPackage.id, input.packageId))
|
| 185 |
.limit(1);
|
| 186 |
|
| 187 |
+
if (!pkg?.isPublic) {
|
| 188 |
+
assertOwnership(pkg, userId, "Package");
|
| 189 |
+
}
|
| 190 |
|
| 191 |
const sections = await db
|
| 192 |
.select({
|
packages/api/src/routers/index.ts
CHANGED
|
@@ -10,6 +10,7 @@ import { ratingRouter } from "./rating";
|
|
| 10 |
import { settingsRouter } from "./settings";
|
| 11 |
import { leaderboardRouter } from "./leaderboard";
|
| 12 |
import { statsRouter } from "./stats";
|
|
|
|
| 13 |
|
| 14 |
export const appRouter = router({
|
| 15 |
healthCheck: publicProcedure.query(() => {
|
|
@@ -32,6 +33,7 @@ export const appRouter = router({
|
|
| 32 |
settings: settingsRouter,
|
| 33 |
stats: statsRouter,
|
| 34 |
leaderboard: leaderboardRouter,
|
|
|
|
| 35 |
});
|
| 36 |
|
| 37 |
export type AppRouter = typeof appRouter;
|
|
|
|
| 10 |
import { settingsRouter } from "./settings";
|
| 11 |
import { leaderboardRouter } from "./leaderboard";
|
| 12 |
import { statsRouter } from "./stats";
|
| 13 |
+
import { verificationRouter } from "./verification";
|
| 14 |
|
| 15 |
export const appRouter = router({
|
| 16 |
healthCheck: publicProcedure.query(() => {
|
|
|
|
| 33 |
settings: settingsRouter,
|
| 34 |
stats: statsRouter,
|
| 35 |
leaderboard: leaderboardRouter,
|
| 36 |
+
verification: verificationRouter,
|
| 37 |
});
|
| 38 |
|
| 39 |
export type AppRouter = typeof appRouter;
|
packages/api/src/routers/package.ts
CHANGED
|
@@ -303,6 +303,26 @@ export const packageRouter = router({
|
|
| 303 |
return { success: true };
|
| 304 |
}),
|
| 305 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 306 |
// ── Section Management ───────────────────────────────────
|
| 307 |
|
| 308 |
addSection: protectedProcedure
|
|
|
|
| 303 |
return { success: true };
|
| 304 |
}),
|
| 305 |
|
| 306 |
+
bulkPublish: protectedProcedure
|
| 307 |
+
.input(z.object({ ids: z.array(z.string().uuid()).min(1) }))
|
| 308 |
+
.mutation(async ({ ctx, input }) => {
|
| 309 |
+
const rows = await db
|
| 310 |
+
.select({ id: testPackage.id, creatorUserId: testPackage.creatorUserId, isPublic: testPackage.isPublic })
|
| 311 |
+
.from(testPackage)
|
| 312 |
+
.where(inArray(testPackage.id, input.ids));
|
| 313 |
+
|
| 314 |
+
for (const row of rows) {
|
| 315 |
+
assertOwnership(row, ctx.session.user.id, "Package");
|
| 316 |
+
}
|
| 317 |
+
|
| 318 |
+
await db
|
| 319 |
+
.update(testPackage)
|
| 320 |
+
.set({ isPublic: true })
|
| 321 |
+
.where(inArray(testPackage.id, input.ids));
|
| 322 |
+
|
| 323 |
+
return { success: true, updated: rows.length };
|
| 324 |
+
}),
|
| 325 |
+
|
| 326 |
// ── Section Management ───────────────────────────────────
|
| 327 |
|
| 328 |
addSection: protectedProcedure
|
packages/api/src/routers/verification.ts
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { createDb } from "@labas/db";
|
| 2 |
+
import { verification, user, account } from "@labas/db/schema/auth";
|
| 3 |
+
import { TRPCError } from "@trpc/server";
|
| 4 |
+
import bcrypt from "bcryptjs";
|
| 5 |
+
import crypto from "node:crypto";
|
| 6 |
+
import { eq, and, gt, sql } from "drizzle-orm";
|
| 7 |
+
import { z } from "zod";
|
| 8 |
+
|
| 9 |
+
import { sendOtpEmail } from "../lib/email";
|
| 10 |
+
import { publicProcedure, router } from "../index";
|
| 11 |
+
|
| 12 |
+
const OTP_LENGTH = 6;
|
| 13 |
+
const OTP_EXPIRY_MS = 5 * 60 * 1000;
|
| 14 |
+
|
| 15 |
+
function generateOtp(): string {
|
| 16 |
+
return crypto.randomInt(10 ** (OTP_LENGTH - 1), 10 ** OTP_LENGTH - 1).toString();
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
export const verificationRouter = router({
|
| 20 |
+
sendVerificationOtp: publicProcedure
|
| 21 |
+
.input(z.object({ email: z.string().email() }))
|
| 22 |
+
.mutation(async ({ input }) => {
|
| 23 |
+
const db = createDb();
|
| 24 |
+
|
| 25 |
+
const [existingUser] = await db
|
| 26 |
+
.select({ id: user.id, emailVerified: user.emailVerified })
|
| 27 |
+
.from(user)
|
| 28 |
+
.where(eq(user.email, input.email))
|
| 29 |
+
.limit(1);
|
| 30 |
+
|
| 31 |
+
if (!existingUser) {
|
| 32 |
+
throw new TRPCError({ code: "NOT_FOUND", message: "Email not found" });
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
if (existingUser.emailVerified) {
|
| 36 |
+
throw new TRPCError({ code: "BAD_REQUEST", message: "Email already verified" });
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
const otp = generateOtp();
|
| 40 |
+
const identifier = `email-verification:${input.email}`;
|
| 41 |
+
|
| 42 |
+
await db.delete(verification).where(eq(verification.identifier, identifier));
|
| 43 |
+
|
| 44 |
+
await db.insert(verification).values({
|
| 45 |
+
id: crypto.randomUUID(),
|
| 46 |
+
identifier,
|
| 47 |
+
value: otp,
|
| 48 |
+
expiresAt: new Date(Date.now() + OTP_EXPIRY_MS),
|
| 49 |
+
});
|
| 50 |
+
|
| 51 |
+
await sendOtpEmail({ to: input.email, otp, type: "email-verification" });
|
| 52 |
+
|
| 53 |
+
return { success: true };
|
| 54 |
+
}),
|
| 55 |
+
|
| 56 |
+
verifyEmailOtp: publicProcedure
|
| 57 |
+
.input(z.object({ email: z.string().email(), otp: z.string().length(6) }))
|
| 58 |
+
.mutation(async ({ input }) => {
|
| 59 |
+
const db = createDb();
|
| 60 |
+
|
| 61 |
+
const identifier = `email-verification:${input.email}`;
|
| 62 |
+
const [record] = await db
|
| 63 |
+
.select()
|
| 64 |
+
.from(verification)
|
| 65 |
+
.where(
|
| 66 |
+
and(
|
| 67 |
+
eq(verification.identifier, identifier),
|
| 68 |
+
eq(verification.value, input.otp),
|
| 69 |
+
gt(verification.expiresAt, sql`now()`),
|
| 70 |
+
),
|
| 71 |
+
)
|
| 72 |
+
.limit(1);
|
| 73 |
+
|
| 74 |
+
if (!record) {
|
| 75 |
+
throw new TRPCError({ code: "BAD_REQUEST", message: "Invalid or expired OTP" });
|
| 76 |
+
}
|
| 77 |
+
|
| 78 |
+
await db
|
| 79 |
+
.update(user)
|
| 80 |
+
.set({ emailVerified: true })
|
| 81 |
+
.where(eq(user.email, input.email));
|
| 82 |
+
|
| 83 |
+
await db.delete(verification).where(eq(verification.id, record.id));
|
| 84 |
+
|
| 85 |
+
return { success: true };
|
| 86 |
+
}),
|
| 87 |
+
|
| 88 |
+
sendPasswordResetOtp: publicProcedure
|
| 89 |
+
.input(z.object({ email: z.string().email() }))
|
| 90 |
+
.mutation(async ({ input }) => {
|
| 91 |
+
const db = createDb();
|
| 92 |
+
|
| 93 |
+
const [existingUser] = await db
|
| 94 |
+
.select({ id: user.id })
|
| 95 |
+
.from(user)
|
| 96 |
+
.where(eq(user.email, input.email))
|
| 97 |
+
.limit(1);
|
| 98 |
+
|
| 99 |
+
if (!existingUser) {
|
| 100 |
+
throw new TRPCError({ code: "NOT_FOUND", message: "Email not found" });
|
| 101 |
+
}
|
| 102 |
+
|
| 103 |
+
const otp = generateOtp();
|
| 104 |
+
const identifier = `forget-password:${input.email}`;
|
| 105 |
+
|
| 106 |
+
await db.delete(verification).where(eq(verification.identifier, identifier));
|
| 107 |
+
|
| 108 |
+
await db.insert(verification).values({
|
| 109 |
+
id: crypto.randomUUID(),
|
| 110 |
+
identifier,
|
| 111 |
+
value: otp,
|
| 112 |
+
expiresAt: new Date(Date.now() + OTP_EXPIRY_MS),
|
| 113 |
+
});
|
| 114 |
+
|
| 115 |
+
await sendOtpEmail({ to: input.email, otp, type: "forget-password" });
|
| 116 |
+
|
| 117 |
+
return { success: true };
|
| 118 |
+
}),
|
| 119 |
+
|
| 120 |
+
resetPassword: publicProcedure
|
| 121 |
+
.input(
|
| 122 |
+
z.object({
|
| 123 |
+
email: z.string().email(),
|
| 124 |
+
otp: z.string().length(6),
|
| 125 |
+
newPassword: z.string().min(8),
|
| 126 |
+
}),
|
| 127 |
+
)
|
| 128 |
+
.mutation(async ({ input }) => {
|
| 129 |
+
const db = createDb();
|
| 130 |
+
|
| 131 |
+
const identifier = `forget-password:${input.email}`;
|
| 132 |
+
const [record] = await db
|
| 133 |
+
.select()
|
| 134 |
+
.from(verification)
|
| 135 |
+
.where(
|
| 136 |
+
and(
|
| 137 |
+
eq(verification.identifier, identifier),
|
| 138 |
+
eq(verification.value, input.otp),
|
| 139 |
+
gt(verification.expiresAt, sql`now()`),
|
| 140 |
+
),
|
| 141 |
+
)
|
| 142 |
+
.limit(1);
|
| 143 |
+
|
| 144 |
+
if (!record) {
|
| 145 |
+
throw new TRPCError({ code: "BAD_REQUEST", message: "Invalid or expired OTP" });
|
| 146 |
+
}
|
| 147 |
+
|
| 148 |
+
const passwordHash = await bcrypt.hash(input.newPassword, 10);
|
| 149 |
+
|
| 150 |
+
const [userRecord] = await db
|
| 151 |
+
.select({ id: user.id, name: user.name })
|
| 152 |
+
.from(user)
|
| 153 |
+
.where(eq(user.email, input.email))
|
| 154 |
+
.limit(1);
|
| 155 |
+
|
| 156 |
+
if (!userRecord) {
|
| 157 |
+
throw new TRPCError({ code: "NOT_FOUND", message: "User not found" });
|
| 158 |
+
}
|
| 159 |
+
|
| 160 |
+
await db
|
| 161 |
+
.update(account)
|
| 162 |
+
.set({ password: passwordHash })
|
| 163 |
+
.where(
|
| 164 |
+
and(eq(account.providerId, "credential"), eq(account.userId, userRecord.id)),
|
| 165 |
+
);
|
| 166 |
+
|
| 167 |
+
await db.delete(verification).where(eq(verification.id, record.id));
|
| 168 |
+
|
| 169 |
+
return { success: true };
|
| 170 |
+
}),
|
| 171 |
+
});
|
packages/auth/src/index.ts
CHANGED
|
@@ -17,6 +17,11 @@ export function createAuth() {
|
|
| 17 |
emailAndPassword: {
|
| 18 |
enabled: true,
|
| 19 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 20 |
secret: env.BETTER_AUTH_SECRET,
|
| 21 |
baseURL: env.BETTER_AUTH_URL,
|
| 22 |
advanced: {
|
|
|
|
| 17 |
emailAndPassword: {
|
| 18 |
enabled: true,
|
| 19 |
},
|
| 20 |
+
user: {
|
| 21 |
+
changeEmail: {
|
| 22 |
+
enabled: true,
|
| 23 |
+
},
|
| 24 |
+
},
|
| 25 |
secret: env.BETTER_AUTH_SECRET,
|
| 26 |
baseURL: env.BETTER_AUTH_URL,
|
| 27 |
advanced: {
|
packages/env/src/server.ts
CHANGED
|
@@ -10,6 +10,11 @@ export const env = createEnv({
|
|
| 10 |
BETTER_AUTH_URL: z.url(),
|
| 11 |
CORS_ORIGIN: z.url(),
|
| 12 |
NODE_ENV: z.enum(["development", "production", "test"]).default("development"),
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
},
|
| 14 |
runtimeEnv: process.env,
|
| 15 |
emptyStringAsUndefined: true,
|
|
|
|
| 10 |
BETTER_AUTH_URL: z.url(),
|
| 11 |
CORS_ORIGIN: z.url(),
|
| 12 |
NODE_ENV: z.enum(["development", "production", "test"]).default("development"),
|
| 13 |
+
SMTP_HOST: z.string().min(1),
|
| 14 |
+
SMTP_PORT: z.coerce.number().positive().default(587),
|
| 15 |
+
SMTP_USER: z.string().min(1),
|
| 16 |
+
SMTP_PASS: z.string().min(1),
|
| 17 |
+
SMTP_FROM: z.string().min(1),
|
| 18 |
},
|
| 19 |
runtimeEnv: process.env,
|
| 20 |
emptyStringAsUndefined: true,
|
packages/ui/src/components/button.tsx
CHANGED
|
@@ -24,7 +24,9 @@ const buttonVariants = cva(
|
|
| 24 |
"h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
|
| 25 |
xs: "h-6 gap-1 rounded-none px-2 text-xs has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
|
| 26 |
sm: "h-7 gap-1 rounded-none px-2.5 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
|
|
|
|
| 27 |
lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
|
|
|
|
| 28 |
icon: "size-8",
|
| 29 |
"icon-xs": "size-6 rounded-none [&_svg:not([class*='size-'])]:size-3",
|
| 30 |
"icon-sm": "size-7 rounded-none",
|
|
|
|
| 24 |
"h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
|
| 25 |
xs: "h-6 gap-1 rounded-none px-2 text-xs has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
|
| 26 |
sm: "h-7 gap-1 rounded-none px-2.5 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
|
| 27 |
+
md: "h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
|
| 28 |
lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
|
| 29 |
+
xl: "h-11 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
|
| 30 |
icon: "size-8",
|
| 31 |
"icon-xs": "size-6 rounded-none [&_svg:not([class*='size-'])]:size-3",
|
| 32 |
"icon-sm": "size-7 rounded-none",
|