rogasper commited on
Commit
bb6e880
·
1 Parent(s): 02ffd8a

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 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

  • SHA256: e1cdc3c6cd4b582fc724f57be558b3fb03dc72cdc25115185fc2c751b08ada29
  • Pointer size: 132 Bytes
  • Size of remote file: 1.39 MB

Git LFS Details

  • SHA256: e39f5b556433db51ae13ac44126c0f37b1c51b566b711ba4b954d834e99d4362
  • Pointer size: 131 Bytes
  • Size of remote file: 250 kB
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
- mode === "quick"
111
- ? "bg-[var(--pure-white)] text-[var(--clay-black)] clay-shadow"
112
- : "text-[var(--warm-charcoal)] hover:text-[var(--clay-black)]"
 
 
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
- toast.error(error.error.message || error.error.statusText);
 
 
 
 
 
 
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
- <Label htmlFor={field.name}>Password</Label>
 
 
 
 
 
 
 
 
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("Sign up successful");
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-500)] 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,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-500)]" />
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-500)]" />
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-500)] text-[var(--pure-white)] hover:bg-[var(--pomegranate-600)] rounded-[var(--radius-lg)]"
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
- removedJobIdsRef.current.clear();
 
 
 
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-500)]" />
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 !== null && sec.timeSpentSec !== undefined && (
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">Pembahasan Soal</h2>
199
- <div className="space-y-4">
200
- {attempt.sections.map((sec: any, secIdx: number) =>
201
- sec.questions.map((q: any, qIdx: number) => (
202
- <QuestionReviewCard
203
- key={q.id}
204
- q={q}
205
- secIdx={secIdx}
206
- qIdx={qIdx}
207
- ans={sec.answers.find((a: any) => a.questionId === q.id)}
208
- userId={session?.user.id}
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
- questionCount === p.value
346
- ? "bg-[var(--clay-black)] text-[var(--pure-white)] clay-shadow"
347
- : "bg-[var(--pure-white)] text-[var(--warm-charcoal)] hover:bg-[var(--oat-light)] border-2 border-[var(--oat-border)]"
 
 
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 key={pkg.id} className="clay-shadow clay-hover bg-[var(--pure-white)] border-2 border-[var(--oat-border)] rounded-[var(--radius-xl)] h-full flex flex-col">
 
 
 
 
 
 
 
235
  <CardContent className="p-5 flex flex-col h-full">
236
- <Link to="/package/$id" params={{ id: pkg.id }} className="block flex-1">
 
 
 
 
 
 
 
 
237
  <div className="flex items-start justify-between mb-3">
238
  <div className="flex gap-2 flex-wrap">
239
- <span className="px-2.5 py-1 rounded-full bg-[var(--matcha-300)] text-[var(--matcha-800)] text-xs font-semibold">
 
 
 
 
 
 
 
 
 
 
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
- </Link>
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
- <div className="mt-3 pt-3 border-t border-[var(--oat-border)]">
326
- <Button
327
- className="w-full bg-[var(--matcha-600)] text-[var(--pure-white)] hover:bg-[var(--matcha-800)] clay-hover rounded-[var(--radius-lg)]"
328
- onClick={() => routerNavigate({ to: '/package/$id/take', params: { id: pkg.id } })}
329
- >
330
- <MaterialIcon name="play_arrow" className="mr-2" />
331
- Mulai Latihan
332
- </Button>
333
- </div>
 
 
 
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
- assertOwnership(pkg, userId, "Package");
 
 
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",