rogasper commited on
Commit
382b9aa
·
1 Parent(s): 2a80c9b

feat: integrate Umami analytics for user interactions across various components. Track sign-in, sign-up, and test attempt events, enhancing user engagement insights. Update README.md to include new db:seed command and document sticky header patterns for detail pages.

Browse files
AGENTS.md CHANGED
@@ -72,6 +72,7 @@ bun run db:push # Push schema changes to PostgreSQL
72
  bun run db:studio # Open Drizzle Studio UI
73
  bun run db:migrate # Run migrations
74
  bun run db:generate # Generate migration files
 
75
  bun run db:start # Start local DB (if configured)
76
  bun run db:stop # Stop local DB
77
 
@@ -116,9 +117,117 @@ bun run build # Build all packages
116
  - App-level error boundary is in `__root.tsx` using `<ErrorFallback>` — route-level error boundaries via `errorComponent` route option.
117
  - Lazy-loaded route components use the `.lazy.tsx` pattern: route file is thin, heavy component lives in `components/routes/*Page.tsx` wrapped in `Suspense`.
118
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
119
  ---
120
 
121
- ## 5b. Frontend Accessibility Conventions
122
 
123
  - **Skip link**: Root layout in `__root.tsx` has a hidden `<SkipLink>` that appears on Tab press, linking to `#main-content` on the `<main>` element.
124
  - **Icon-only buttons**: Every `<button>` or `<Button>` with only an icon (no visible text) **must** have `aria-label`. The icon element (`<MaterialIcon>`, `<span>`) should NOT have `aria-hidden="true"` unless decorative.
@@ -276,4 +385,4 @@ bun run build # Build all packages
276
 
277
  ---
278
 
279
- _Last updated: 2026-05-19_
 
72
  bun run db:studio # Open Drizzle Studio UI
73
  bun run db:migrate # Run migrations
74
  bun run db:generate # Generate migration files
75
+ bun run db:seed # Seed reference data (exam types, etc.)
76
  bun run db:start # Start local DB (if configured)
77
  bun run db:stop # Stop local DB
78
 
 
117
  - App-level error boundary is in `__root.tsx` using `<ErrorFallback>` — route-level error boundaries via `errorComponent` route option.
118
  - Lazy-loaded route components use the `.lazy.tsx` pattern: route file is thin, heavy component lives in `components/routes/*Page.tsx` wrapped in `Suspense`.
119
 
120
+ ### Sticky Detail Page Header Pattern
121
+
122
+ Detail pages with long scrollable content (e.g. `package.$id.index.tsx`, `attempt.$id.tsx`) use a **sticky top header** pattern:
123
+
124
+ ```tsx
125
+ <div className="min-h-screen pb-32 bg-[var(--warm-cream)]">
126
+ {/* Sticky header */}
127
+ <div className="sticky top-0 z-20 bg-[var(--warm-cream)]/95 backdrop-blur-sm border-b border-[var(--oat-border)] shadow-sm">
128
+ <div className="px-6 md:px-12 lg:px-16 max-w-4xl mx-auto pt-4 pb-4">
129
+ {/* Compact breadcrumb — text-xs, truncated title */}
130
+ {/* Title row (left) + action buttons (right) */}
131
+ {/* Stats row — text-xs */}
132
+ </div>
133
+ </div>
134
+ {/* Scrollable content */}
135
+ <div className="px-6 md:px-12 lg:px-16 max-w-4xl mx-auto pt-8">
136
+ ...
137
+ </div>
138
+ </div>
139
+ ```
140
+
141
+ Rules:
142
+ - Primary CTA ("Mulai Latihan", "Coba Lagi") and secondary actions (Edit, Share) live in the sticky header — **never only at the bottom** of a long page.
143
+ - Breadcrumb inside the header is `text-xs` and truncates the current page title to `max-w-[240px]`.
144
+ - On small screens (`sm:` breakpoint), button text labels hide (`hidden sm:inline`) leaving only icons.
145
+
146
+ ### Public/Private Toggle Hover Pattern
147
+
148
+ Any button that toggles visibility between public and private must reveal **intent on hover** (what will happen, not what it currently is):
149
+
150
+ ```tsx
151
+ <button
152
+ onClick={onTogglePublic}
153
+ aria-label={isPublic ? "Jadikan privat" : "Jadikan publik"}
154
+ className={`group ... ${isPublic
155
+ ? "bg-[var(--matcha-300)] text-[var(--matcha-800)] hover:bg-[var(--pomegranate-400)]/15 hover:text-[var(--pomegranate-600)]"
156
+ : "bg-[var(--slushie-500)]/15 text-[var(--slushie-800)] hover:bg-[var(--matcha-300)] hover:text-[var(--matcha-800)]"
157
+ }`}
158
+ >
159
+ <MaterialIcon name={isPublic ? "public" : "lock"} className="text-xs group-hover:hidden" />
160
+ <MaterialIcon name={isPublic ? "lock" : "public"} className="text-xs hidden group-hover:inline" />
161
+ <span className="group-hover:hidden">{isPublic ? "Publik" : "Privat"}</span>
162
+ <span className="hidden group-hover:inline">{isPublic ? "Jadikan Privat" : "Jadikan Publik"}</span>
163
+ </button>
164
+ ```
165
+
166
+ This pattern is used in `QuestionCard.tsx` and `PackageCard.tsx`.
167
+
168
+ ### Component Folder Conventions
169
+
170
+ Large "card" components for list items are extracted into their own folder and file, **not** inlined inside the page component:
171
+
172
+ | Domain | Card component | Used in |
173
+ |--------|---------------|---------|
174
+ | Questions | `apps/web/src/components/bank/QuestionCard.tsx` | `BankPage.tsx` |
175
+ | Packages | `apps/web/src/components/packages/PackageCard.tsx` | `PackagesPage.tsx` |
176
+
177
+ When a list item card grows beyond ~40 lines of JSX, extract it. Pass all state through props; the card must be stateless (no data fetching).
178
+
179
+ ### CalloutCard
180
+
181
+ `apps/web/src/components/bank/CalloutCard.tsx` is a reusable banner for "N items are private, publish them all" prompts. Use the `label` prop to customise the noun:
182
+
183
+ ```tsx
184
+ <CalloutCard count={privateCount} label="soal" onPublishAll={handlePublishAllPrivate} />
185
+ <CalloutCard count={privateCount} label="paket" onPublishAll={handlePublishAllPrivate} />
186
+ ```
187
+
188
+ `count` should come from a **dedicated query with `limit: 1`** (fetches only the `total` field), **not** from the locally accumulated infinite-scroll array — those counts are partial and misleading.
189
+
190
+ ---
191
+
192
+ ## 5b. Route Shell & Layout Modes
193
+
194
+ ### Route Shell System
195
+
196
+ Every route declares its layout via `staticData` from `apps/web/src/lib/route-shell.ts`. The root layout (`__root.tsx`) reads this to decide which shell to render:
197
+
198
+ | Shell | When to use | What renders |
199
+ |-------|-------------|-------------|
200
+ | `routeShell.app` | Authenticated app pages | Sidebar + `<main id="main-content">` + SkipLink |
201
+ | `routeShell.public` | Landing, login, auth flows, 404 | Bare `min-h-screen` wrapper, no sidebar |
202
+ | `routeShell.fullscreen` | Test-taking, immersive UIs | `h-screen overflow-y-auto`, no sidebar |
203
+
204
+ Usage in a route file:
205
+ ```tsx
206
+ import { routeShell } from "@/lib/route-shell";
207
+
208
+ export const Route = createFileRoute("/my-route")({
209
+ staticData: routeShell.app, // or .public / .fullscreen
210
+ ...
211
+ });
212
+ ```
213
+
214
+ **Rules:**
215
+ - **Always** set `staticData` on every route. Routes without it default to `"app"` shell (sidebar shown).
216
+ - Auth routes (`/login`, `/forgot-password`, `/verify-email`, `/setup-avatar`) use `routeShell.public`.
217
+ - Test routes (`/package/$id/take`, `/package/$id/attempt/$attemptId`) use `routeShell.fullscreen`.
218
+ - Admin routes (`/admin`, `/admin/*`) use `routeShell.fullscreen`.
219
+
220
+ ### Route Structure Conventions
221
+
222
+ - **`/`** — Public landing page (`index.tsx` + `index.lazy.tsx`). Redirects logged-in users to `/dashboard`.
223
+ - **`/dashboard`** — Authenticated home page (`dashboard.tsx`). Redirects unauthenticated users to `/login`.
224
+ - **`/landing`** — Legacy redirect → `/` (keep for backward compat, do not remove).
225
+ - **`/$.tsx`** — Catch-all 404 route. Uses `routeShell.public` so no sidebar appears. Renders `<NotFoundPage>`.
226
+ - **Do NOT** put a `notFoundComponent` in `__root.tsx`. The 404 is handled exclusively by the splat route `$.tsx`.
227
+
228
  ---
229
 
230
+ ## 5c. Frontend Accessibility Conventions
231
 
232
  - **Skip link**: Root layout in `__root.tsx` has a hidden `<SkipLink>` that appears on Tab press, linking to `#main-content` on the `<main>` element.
233
  - **Icon-only buttons**: Every `<button>` or `<Button>` with only an icon (no visible text) **must** have `aria-label`. The icon element (`<MaterialIcon>`, `<span>`) should NOT have `aria-hidden="true"` unless decorative.
 
385
 
386
  ---
387
 
388
+ _Last updated: 2026-05-25_
apps/web/src/components/routes/GeneratePage.tsx CHANGED
@@ -5,6 +5,7 @@ import { authClient } from "@/lib/auth-client";
5
  import { trpc } from "@/utils/trpc";
6
  import { useApiKeys } from "@/hooks/use-api-key";
7
  import { useGenerationJobs, type CompletedResult } from "@/hooks/use-generation-jobs";
 
8
  import { Button } from "@labas/ui/components/button";
9
  import {
10
  Select,
@@ -181,6 +182,18 @@ export function RouteComponent() {
181
  maxTokens: selectedConfig!.maxTokens ?? 16384,
182
  };
183
 
 
 
 
 
 
 
 
 
 
 
 
 
184
  type GenerateMutateInput = Parameters<typeof generate.mutate>[0];
185
  generate.mutate({
186
  examType,
 
5
  import { trpc } from "@/utils/trpc";
6
  import { useApiKeys } from "@/hooks/use-api-key";
7
  import { useGenerationJobs, type CompletedResult } from "@/hooks/use-generation-jobs";
8
+ import { trackUmamiEvent, AnalyticsEvent } from "@/lib/umami";
9
  import { Button } from "@labas/ui/components/button";
10
  import {
11
  Select,
 
182
  maxTokens: selectedConfig!.maxTokens ?? 16384,
183
  };
184
 
185
+ const eventName = mode === "agentic" ? AnalyticsEvent.AI_GENERATE_AGENTIC : AnalyticsEvent.AI_GENERATE_QUICK;
186
+ trackUmamiEvent(eventName, {
187
+ exam_type: examType,
188
+ mode,
189
+ sections: selectedSections,
190
+ formats: selectedFormats,
191
+ question_count: questionCount,
192
+ topics: selectedTopics,
193
+ difficulty: difficulty + 1,
194
+ use_free_credits: useFreeCredits,
195
+ });
196
+
197
  type GenerateMutateInput = Parameters<typeof generate.mutate>[0];
198
  generate.mutate({
199
  examType,
apps/web/src/components/routes/HistoryPage.tsx CHANGED
@@ -4,6 +4,7 @@ import { createFileRoute, redirect, Link } from "@tanstack/react-router";
4
  import { z } from "zod";
5
  import { authClient } from "@/lib/auth-client";
6
  import { trpc } from "@/utils/trpc";
 
7
  import { Button } from "@labas/ui/components/button";
8
  import { Card, CardContent } from "@labas/ui/components/card";
9
  import { Input } from "@labas/ui/components/input";
@@ -86,6 +87,10 @@ export function HistoryComponent() {
86
  setLocalSearch(searchQuery);
87
  }, [searchQuery]);
88
 
 
 
 
 
89
  useEffect(() => {
90
  if (debounceRef.current) clearTimeout(debounceRef.current);
91
  debounceRef.current = setTimeout(() => {
 
4
  import { z } from "zod";
5
  import { authClient } from "@/lib/auth-client";
6
  import { trpc } from "@/utils/trpc";
7
+ import { trackUmamiEvent, AnalyticsEvent } from "@/lib/umami";
8
  import { Button } from "@labas/ui/components/button";
9
  import { Card, CardContent } from "@labas/ui/components/card";
10
  import { Input } from "@labas/ui/components/input";
 
87
  setLocalSearch(searchQuery);
88
  }, [searchQuery]);
89
 
90
+ useEffect(() => {
91
+ trackUmamiEvent(AnalyticsEvent.VIEW_HISTORY);
92
+ }, []);
93
+
94
  useEffect(() => {
95
  if (debounceRef.current) clearTimeout(debounceRef.current);
96
  debounceRef.current = setTimeout(() => {
apps/web/src/components/sign-in-form.tsx CHANGED
@@ -8,6 +8,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
 
@@ -30,6 +31,7 @@ export default function SignInForm({ onSwitchToSignUp }: { onSwitchToSignUp: ()
30
  },
31
  {
32
  onSuccess: () => {
 
33
  navigate({ to: "/dashboard" });
34
  toast.success("Sign in successful");
35
  },
 
8
  import z from "zod";
9
 
10
  import { authClient } from "@/lib/auth-client";
11
+ import { trackUmamiEvent, AnalyticsEvent } from "@/lib/umami";
12
 
13
  import Loader from "./loader";
14
 
 
31
  },
32
  {
33
  onSuccess: () => {
34
+ trackUmamiEvent(AnalyticsEvent.SIGN_IN);
35
  navigate({ to: "/dashboard" });
36
  toast.success("Sign in successful");
37
  },
apps/web/src/components/sign-up-form.tsx CHANGED
@@ -10,6 +10,7 @@ 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
 
@@ -35,6 +36,7 @@ export default function SignUpForm({ onSwitchToSignIn }: { onSwitchToSignIn: ()
35
  },
36
  {
37
  onSuccess: async () => {
 
38
  try {
39
  await sendOtpMutation.mutateAsync({ email: value.email });
40
  } catch {}
 
10
 
11
  import { authClient } from "@/lib/auth-client";
12
  import { trpc } from "@/utils/trpc";
13
+ import { trackUmamiEvent, AnalyticsEvent } from "@/lib/umami";
14
 
15
  import Loader from "./loader";
16
 
 
36
  },
37
  {
38
  onSuccess: async () => {
39
+ trackUmamiEvent(AnalyticsEvent.SIGN_UP);
40
  try {
41
  await sendOtpMutation.mutateAsync({ email: value.email });
42
  } catch {}
apps/web/src/hooks/use-api-key.ts CHANGED
@@ -1,5 +1,6 @@
1
  import { useState, useEffect, useCallback } from "react";
2
  import { encryptText, decryptText } from "@/lib/crypto";
 
3
 
4
  const STORAGE_KEY = "labas_api_keys_v2";
5
 
@@ -49,6 +50,7 @@ export function useApiKeys() {
49
  async (config: Omit<ApiKeyConfig, "id">) => {
50
  const next = [...configs, { ...config, id: generateId() }];
51
  await persist(next);
 
52
  return next[next.length - 1].id;
53
  },
54
  [configs, persist],
@@ -72,8 +74,12 @@ export function useApiKeys() {
72
 
73
  const removeConfig = useCallback(
74
  async (id: string) => {
 
75
  const next = configs.filter((c) => c.id !== id);
76
  await persist(next);
 
 
 
77
  },
78
  [configs, persist],
79
  );
 
1
  import { useState, useEffect, useCallback } from "react";
2
  import { encryptText, decryptText } from "@/lib/crypto";
3
+ import { trackUmamiEvent, AnalyticsEvent } from "@/lib/umami";
4
 
5
  const STORAGE_KEY = "labas_api_keys_v2";
6
 
 
50
  async (config: Omit<ApiKeyConfig, "id">) => {
51
  const next = [...configs, { ...config, id: generateId() }];
52
  await persist(next);
53
+ trackUmamiEvent(AnalyticsEvent.API_KEY_ADDED, { provider: config.provider });
54
  return next[next.length - 1].id;
55
  },
56
  [configs, persist],
 
74
 
75
  const removeConfig = useCallback(
76
  async (id: string) => {
77
+ const target = configs.find((c) => c.id === id);
78
  const next = configs.filter((c) => c.id !== id);
79
  await persist(next);
80
+ if (target) {
81
+ trackUmamiEvent(AnalyticsEvent.API_KEY_REMOVED, { provider: target.provider });
82
+ }
83
  },
84
  [configs, persist],
85
  );
apps/web/src/hooks/use-test-session.ts CHANGED
@@ -212,11 +212,12 @@ export function useTestSession(packageId: string, existingAttemptId?: string) {
212
  if (!attemptId) return;
213
  setIsFinished(true);
214
  localStorage.setItem("pendingDonationPrompt", "exam");
215
- await finishMutation.mutateAsync({ attemptId });
216
  clearElapsedTime(attemptId);
217
  clearMarkedQuestions(attemptId);
218
  clearSectionIdx(attemptId);
219
  navigate({ to: "/attempt/$id", params: { id: attemptId } });
 
220
  }, [attemptId, finishMutation, navigate]);
221
 
222
  const handleAbandon = useCallback(async () => {
 
212
  if (!attemptId) return;
213
  setIsFinished(true);
214
  localStorage.setItem("pendingDonationPrompt", "exam");
215
+ const result = await finishMutation.mutateAsync({ attemptId });
216
  clearElapsedTime(attemptId);
217
  clearMarkedQuestions(attemptId);
218
  clearSectionIdx(attemptId);
219
  navigate({ to: "/attempt/$id", params: { id: attemptId } });
220
+ return result;
221
  }, [attemptId, finishMutation, navigate]);
222
 
223
  const handleAbandon = useCallback(async () => {
apps/web/src/lib/umami.ts ADDED
@@ -0,0 +1,72 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ export const AnalyticsEvent = {
2
+ ATTEMPT_START: "attempt_start",
3
+ ATTEMPT_FINISH: "attempt_finish",
4
+ ATTEMPT_ABANDON: "attempt_abandon",
5
+ AI_GENERATE_QUICK: "ai_generate_quick",
6
+ AI_GENERATE_AGENTIC: "ai_generate_agentic",
7
+ SIGN_IN: "sign_in",
8
+ SIGN_UP: "sign_up",
9
+ API_KEY_ADDED: "api_key_added",
10
+ API_KEY_REMOVED: "api_key_removed",
11
+ VIEW_ANALYTICS: "view_analytics",
12
+ VIEW_HISTORY: "view_history",
13
+ } as const;
14
+
15
+ export type AnalyticsEventName = (typeof AnalyticsEvent)[keyof typeof AnalyticsEvent];
16
+
17
+ export interface AttemptStartPayload {
18
+ exam_type: string;
19
+ question_count: number;
20
+ }
21
+
22
+ export interface AttemptFinishPayload {
23
+ exam_type: string;
24
+ score: number;
25
+ max_score: number;
26
+ percentage: number;
27
+ time_elapsed_sec: number;
28
+ }
29
+
30
+ export interface AttemptAbandonPayload {
31
+ exam_type: string;
32
+ questions_answered: number;
33
+ total_questions: number;
34
+ }
35
+
36
+ export interface AiGeneratePayload {
37
+ exam_type: string;
38
+ mode: string;
39
+ sections: string[];
40
+ formats: string[];
41
+ question_count: number;
42
+ topics: string[];
43
+ difficulty: number;
44
+ use_free_credits: boolean;
45
+ }
46
+
47
+ export interface ApiKeyPayload {
48
+ provider: string;
49
+ }
50
+
51
+ type EventPayloadMap = {
52
+ [AnalyticsEvent.ATTEMPT_START]: AttemptStartPayload;
53
+ [AnalyticsEvent.ATTEMPT_FINISH]: AttemptFinishPayload;
54
+ [AnalyticsEvent.ATTEMPT_ABANDON]: AttemptAbandonPayload;
55
+ [AnalyticsEvent.AI_GENERATE_QUICK]: AiGeneratePayload;
56
+ [AnalyticsEvent.AI_GENERATE_AGENTIC]: AiGeneratePayload;
57
+ [AnalyticsEvent.API_KEY_ADDED]: ApiKeyPayload;
58
+ [AnalyticsEvent.API_KEY_REMOVED]: ApiKeyPayload;
59
+ [AnalyticsEvent.SIGN_IN]: Record<string, never>;
60
+ [AnalyticsEvent.SIGN_UP]: Record<string, never>;
61
+ [AnalyticsEvent.VIEW_ANALYTICS]: Record<string, never>;
62
+ [AnalyticsEvent.VIEW_HISTORY]: Record<string, never>;
63
+ };
64
+
65
+ export function trackUmamiEvent<T extends AnalyticsEventName>(
66
+ eventName: T,
67
+ payload?: T extends keyof EventPayloadMap ? EventPayloadMap[T] : never,
68
+ ): void {
69
+ if (import.meta.env.DEV) return;
70
+ if (typeof window === "undefined" || !window.umami) return;
71
+ window.umami.track(eventName, payload as Record<string, unknown>);
72
+ }
apps/web/src/routes/analytics.tsx CHANGED
@@ -1,7 +1,8 @@
1
- import { Suspense, lazy } from "react";
2
  import { createFileRoute, redirect, Link } from "@tanstack/react-router";
3
  import { authClient } from "@/lib/auth-client";
4
  import { useAnalytics } from "@/hooks/use-analytics";
 
5
  import { MaterialIcon } from "@/components/ui/MaterialIcon";
6
  import { OverviewCards } from "@/components/analytics/OverviewCards";
7
  import { WeaknessPanel } from "@/components/analytics/WeaknessPanel";
@@ -40,6 +41,10 @@ function AnalyticsComponent() {
40
  isLoading,
41
  } = useAnalytics();
42
 
 
 
 
 
43
  if (isLoading) {
44
  return (
45
  <div className="min-h-screen pt-8 pb-32 px-6 md:px-12 lg:px-16 max-w-7xl mx-auto bg-[var(--warm-cream)]">
 
1
+ import { Suspense, lazy, useEffect } from "react";
2
  import { createFileRoute, redirect, Link } from "@tanstack/react-router";
3
  import { authClient } from "@/lib/auth-client";
4
  import { useAnalytics } from "@/hooks/use-analytics";
5
+ import { trackUmamiEvent, AnalyticsEvent } from "@/lib/umami";
6
  import { MaterialIcon } from "@/components/ui/MaterialIcon";
7
  import { OverviewCards } from "@/components/analytics/OverviewCards";
8
  import { WeaknessPanel } from "@/components/analytics/WeaknessPanel";
 
41
  isLoading,
42
  } = useAnalytics();
43
 
44
+ useEffect(() => {
45
+ trackUmamiEvent(AnalyticsEvent.VIEW_ANALYTICS);
46
+ }, []);
47
+
48
  if (isLoading) {
49
  return (
50
  <div className="min-h-screen pt-8 pb-32 px-6 md:px-12 lg:px-16 max-w-7xl mx-auto bg-[var(--warm-cream)]">
apps/web/src/routes/package.$id.attempt.$attemptId.tsx CHANGED
@@ -1,8 +1,10 @@
 
1
  import { useQuery } from "@tanstack/react-query";
2
  import { createFileRoute, redirect, Link } from "@tanstack/react-router";
3
  import { authClient } from "@/lib/auth-client";
4
  import { trpc } from "@/utils/trpc";
5
  import { useTestSession } from "@/hooks/use-test-session";
 
6
  import { Button } from "@labas/ui/components/button";
7
  import { MaterialIcon } from "@/components/ui/MaterialIcon";
8
  import { AttemptTestView } from "@/components/test/AttemptTestView";
@@ -67,6 +69,28 @@ function ContinueAttemptComponent() {
67
  const totalQuestions = pkg.sections.reduce((sum: number, sec) => sum + sec.questions.length, 0);
68
  const answeredCount = Object.keys(answers).length;
69
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
70
  const currentSection = pkg.sections[currentSectionIdx];
71
  if (!currentSection) {
72
  return (
@@ -74,7 +98,7 @@ function ContinueAttemptComponent() {
74
  <div className="text-center py-20">
75
  <MaterialIcon name="check_circle" className="text-6xl text-[var(--matcha-600)] mx-auto mb-4" />
76
  <p className="text-xl font-headline font-bold text-[var(--clay-black)]">Semua section selesai!</p>
77
- <Button onClick={handleFinish} className="mt-6 bg-[var(--clay-black)] text-[var(--pure-white)] clay-hover rounded-[var(--radius-lg)]">
78
  Selesaikan & Lihat Hasil
79
  </Button>
80
  </div>
@@ -93,8 +117,8 @@ function ContinueAttemptComponent() {
93
  timeElapsed={timeElapsed}
94
  answeredCount={answeredCount}
95
  totalQuestions={totalQuestions}
96
- onFinish={handleFinish}
97
- onAbandon={handleAbandon}
98
  isFinished={isFinished}
99
  submittingQId={submittingQId}
100
  markedQuestions={markedQuestions}
 
1
+ import { useCallback } from "react";
2
  import { useQuery } from "@tanstack/react-query";
3
  import { createFileRoute, redirect, Link } from "@tanstack/react-router";
4
  import { authClient } from "@/lib/auth-client";
5
  import { trpc } from "@/utils/trpc";
6
  import { useTestSession } from "@/hooks/use-test-session";
7
+ import { trackUmamiEvent, AnalyticsEvent } from "@/lib/umami";
8
  import { Button } from "@labas/ui/components/button";
9
  import { MaterialIcon } from "@/components/ui/MaterialIcon";
10
  import { AttemptTestView } from "@/components/test/AttemptTestView";
 
69
  const totalQuestions = pkg.sections.reduce((sum: number, sec) => sum + sec.questions.length, 0);
70
  const answeredCount = Object.keys(answers).length;
71
 
72
+ const handleFinishWithTracking = useCallback(async () => {
73
+ const result = await handleFinish();
74
+ if (result) {
75
+ trackUmamiEvent(AnalyticsEvent.ATTEMPT_FINISH, {
76
+ exam_type: pkg.examTypeName ?? "unknown",
77
+ score: result.totalScore,
78
+ max_score: result.maxScore,
79
+ percentage: result.percentage,
80
+ time_elapsed_sec: timeElapsed,
81
+ });
82
+ }
83
+ }, [handleFinish, pkg.examTypeName, timeElapsed]);
84
+
85
+ const handleAbandonWithTracking = useCallback(async () => {
86
+ await handleAbandon();
87
+ trackUmamiEvent(AnalyticsEvent.ATTEMPT_ABANDON, {
88
+ exam_type: pkg.examTypeName ?? "unknown",
89
+ questions_answered: answeredCount,
90
+ total_questions: totalQuestions,
91
+ });
92
+ }, [handleAbandon, pkg.examTypeName, answeredCount, totalQuestions]);
93
+
94
  const currentSection = pkg.sections[currentSectionIdx];
95
  if (!currentSection) {
96
  return (
 
98
  <div className="text-center py-20">
99
  <MaterialIcon name="check_circle" className="text-6xl text-[var(--matcha-600)] mx-auto mb-4" />
100
  <p className="text-xl font-headline font-bold text-[var(--clay-black)]">Semua section selesai!</p>
101
+ <Button onClick={handleFinishWithTracking} className="mt-6 bg-[var(--clay-black)] text-[var(--pure-white)] clay-hover rounded-[var(--radius-lg)]">
102
  Selesaikan & Lihat Hasil
103
  </Button>
104
  </div>
 
117
  timeElapsed={timeElapsed}
118
  answeredCount={answeredCount}
119
  totalQuestions={totalQuestions}
120
+ onFinish={handleFinishWithTracking}
121
+ onAbandon={handleAbandonWithTracking}
122
  isFinished={isFinished}
123
  submittingQId={submittingQId}
124
  markedQuestions={markedQuestions}
apps/web/src/routes/package.$id.take.tsx CHANGED
@@ -1,9 +1,10 @@
1
- import { useEffect } from "react";
2
  import { useQuery } from "@tanstack/react-query";
3
  import { createFileRoute, redirect, Link, useNavigate } from "@tanstack/react-router";
4
  import { authClient } from "@/lib/auth-client";
5
  import { trpc } from "@/utils/trpc";
6
  import { useTestSession } from "@/hooks/use-test-session";
 
7
  import { Button } from "@labas/ui/components/button";
8
  import { Card, CardContent } from "@labas/ui/components/card";
9
  import { MaterialIcon } from "@/components/ui/MaterialIcon";
@@ -92,6 +93,36 @@ function TakeTestComponent() {
92
  const totalQuestions = pkg.sections.reduce((sum: number, sec) => sum + sec.questions.length, 0);
93
  const answeredCount = Object.keys(answers).length;
94
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
95
  // Start screen
96
  if (!isStarted) {
97
  return (
@@ -144,7 +175,7 @@ function TakeTestComponent() {
144
  )}
145
 
146
  <Button
147
- onClick={handleStart}
148
  disabled={startPending}
149
  className="bg-[var(--clay-black)] text-[var(--pure-white)] hover:bg-[var(--warm-charcoal)] clay-hover rounded-[var(--radius-lg)] px-8 py-6 text-lg"
150
  >
@@ -174,7 +205,7 @@ function TakeTestComponent() {
174
  <div className="text-center py-20">
175
  <MaterialIcon name="check_circle" className="text-6xl text-[var(--matcha-600)] mx-auto mb-4" />
176
  <p className="text-xl font-headline font-bold text-[var(--clay-black)]">Semua section selesai!</p>
177
- <Button onClick={handleFinish} className="mt-6 bg-[var(--clay-black)] text-[var(--pure-white)] clay-hover rounded-[var(--radius-lg)]">
178
  Selesaikan & Lihat Hasil
179
  </Button>
180
  </div>
@@ -193,8 +224,8 @@ function TakeTestComponent() {
193
  timeElapsed={timeElapsed}
194
  answeredCount={answeredCount}
195
  totalQuestions={totalQuestions}
196
- onFinish={handleFinish}
197
- onAbandon={handleAbandon}
198
  isFinished={isFinished}
199
  submittingQId={submittingQId}
200
  markedQuestions={markedQuestions}
 
1
+ import { useEffect, useCallback } from "react";
2
  import { useQuery } from "@tanstack/react-query";
3
  import { createFileRoute, redirect, Link, useNavigate } from "@tanstack/react-router";
4
  import { authClient } from "@/lib/auth-client";
5
  import { trpc } from "@/utils/trpc";
6
  import { useTestSession } from "@/hooks/use-test-session";
7
+ import { trackUmamiEvent, AnalyticsEvent } from "@/lib/umami";
8
  import { Button } from "@labas/ui/components/button";
9
  import { Card, CardContent } from "@labas/ui/components/card";
10
  import { MaterialIcon } from "@/components/ui/MaterialIcon";
 
93
  const totalQuestions = pkg.sections.reduce((sum: number, sec) => sum + sec.questions.length, 0);
94
  const answeredCount = Object.keys(answers).length;
95
 
96
+ const handleStartWithTracking = useCallback(async () => {
97
+ await handleStart();
98
+ trackUmamiEvent(AnalyticsEvent.ATTEMPT_START, {
99
+ exam_type: pkg.examTypeName ?? "unknown",
100
+ question_count: totalQuestions,
101
+ });
102
+ }, [handleStart, pkg.examTypeName, totalQuestions]);
103
+
104
+ const handleFinishWithTracking = useCallback(async () => {
105
+ const result = await handleFinish();
106
+ if (result) {
107
+ trackUmamiEvent(AnalyticsEvent.ATTEMPT_FINISH, {
108
+ exam_type: pkg.examTypeName ?? "unknown",
109
+ score: result.totalScore,
110
+ max_score: result.maxScore,
111
+ percentage: result.percentage,
112
+ time_elapsed_sec: timeElapsed,
113
+ });
114
+ }
115
+ }, [handleFinish, pkg.examTypeName, timeElapsed]);
116
+
117
+ const handleAbandonWithTracking = useCallback(async () => {
118
+ await handleAbandon();
119
+ trackUmamiEvent(AnalyticsEvent.ATTEMPT_ABANDON, {
120
+ exam_type: pkg.examTypeName ?? "unknown",
121
+ questions_answered: answeredCount,
122
+ total_questions: totalQuestions,
123
+ });
124
+ }, [handleAbandon, pkg.examTypeName, answeredCount, totalQuestions]);
125
+
126
  // Start screen
127
  if (!isStarted) {
128
  return (
 
175
  )}
176
 
177
  <Button
178
+ onClick={handleStartWithTracking}
179
  disabled={startPending}
180
  className="bg-[var(--clay-black)] text-[var(--pure-white)] hover:bg-[var(--warm-charcoal)] clay-hover rounded-[var(--radius-lg)] px-8 py-6 text-lg"
181
  >
 
205
  <div className="text-center py-20">
206
  <MaterialIcon name="check_circle" className="text-6xl text-[var(--matcha-600)] mx-auto mb-4" />
207
  <p className="text-xl font-headline font-bold text-[var(--clay-black)]">Semua section selesai!</p>
208
+ <Button onClick={handleFinishWithTracking} className="mt-6 bg-[var(--clay-black)] text-[var(--pure-white)] clay-hover rounded-[var(--radius-lg)]">
209
  Selesaikan & Lihat Hasil
210
  </Button>
211
  </div>
 
224
  timeElapsed={timeElapsed}
225
  answeredCount={answeredCount}
226
  totalQuestions={totalQuestions}
227
+ onFinish={handleFinishWithTracking}
228
+ onAbandon={handleAbandonWithTracking}
229
  isFinished={isFinished}
230
  submittingQId={submittingQId}
231
  markedQuestions={markedQuestions}
apps/web/src/types/umami.d.ts ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ interface Window {
2
+ umami?: {
3
+ track: (eventName: string, eventData?: Record<string, unknown>) => void;
4
+ };
5
+ }