From ac3730fe9f48233b3b11ed8423aa7b97ad585be7 Mon Sep 17 00:00:00 2001 From: Mera Date: Sat, 13 Jun 2026 01:33:17 +0700 Subject: [PATCH 1/9] Upgrade Lingora learning and login experience --- src/app/api/admin/content/route.ts | 39 +- src/app/api/practice/attempt/route.ts | 110 +++-- src/app/globals.css | 36 ++ src/app/learn/[slug]/page.tsx | 43 +- src/app/loading.tsx | 21 + src/components/admin-content-studio.tsx | 32 +- src/components/app-shell.tsx | 30 +- src/components/auth-form.tsx | 227 ++++++++--- src/components/billing-center.tsx | 30 +- src/components/competition-center.tsx | 14 +- src/components/dashboard.tsx | 176 +++++++- src/components/learning-workspace.tsx | 382 ++++++++++++++++-- src/components/lingora-mascot.tsx | 286 +++++++++++-- src/components/roadmap.tsx | 22 +- src/components/settings-hub.tsx | 10 +- src/lib/billing.ts | 12 +- src/lib/competition-data.ts | 3 + src/lib/i18n.test.ts | 16 + src/lib/i18n.ts | 43 +- src/lib/learning-data.ts | 124 +++++- src/lib/practice-scoring.test.ts | 22 + src/lib/practice-scoring.ts | 41 ++ ...12100424_adaptive_levels_i18n_rotation.sql | 345 ++++++++++++++++ .../20260612172137_public_daily_usage_rpc.sql | 38 ++ ...60612180200_harden_private_daily_usage.sql | 2 + 25 files changed, 1828 insertions(+), 276 deletions(-) create mode 100644 src/app/loading.tsx create mode 100644 src/lib/i18n.test.ts create mode 100644 src/lib/practice-scoring.test.ts create mode 100644 src/lib/practice-scoring.ts create mode 100644 supabase/migrations/20260612100424_adaptive_levels_i18n_rotation.sql create mode 100644 supabase/migrations/20260612172137_public_daily_usage_rpc.sql create mode 100644 supabase/migrations/20260612180200_harden_private_daily_usage.sql diff --git a/src/app/api/admin/content/route.ts b/src/app/api/admin/content/route.ts index f89f392..d7fa1bb 100644 --- a/src/app/api/admin/content/route.ts +++ b/src/app/api/admin/content/route.ts @@ -22,7 +22,12 @@ const createSchema = z.discriminatedUnion("kind", [ slug: z.string().trim().min(2).max(80).regex(/^[a-z0-9-]+$/), titleVi: z.string().trim().min(2).max(160), titleEn: z.string().trim().max(160).optional(), + titleJa: z.string().trim().max(160).optional(), + titleTh: z.string().trim().max(160).optional(), descriptionVi: z.string().trim().max(1000).optional(), + descriptionEn: z.string().trim().max(1000).optional(), + descriptionJa: z.string().trim().max(1000).optional(), + descriptionTh: z.string().trim().max(1000).optional(), targetLevel: z.string().trim().min(1).max(20), estimatedHours: z.coerce.number().int().min(1).max(1000), published: z.boolean(), @@ -32,7 +37,13 @@ const createSchema = z.discriminatedUnion("kind", [ pathId: z.uuid(), position: z.coerce.number().int().min(1).max(1000), titleVi: z.string().trim().min(2).max(160), + titleEn: z.string().trim().max(160).optional(), + titleJa: z.string().trim().max(160).optional(), + titleTh: z.string().trim().max(160).optional(), descriptionVi: z.string().trim().max(1000).optional(), + descriptionEn: z.string().trim().max(1000).optional(), + descriptionJa: z.string().trim().max(1000).optional(), + descriptionTh: z.string().trim().max(1000).optional(), skill, level: z.string().trim().min(1).max(20), estimatedMinutes: z.coerce.number().int().min(1).max(300), @@ -41,7 +52,13 @@ const createSchema = z.discriminatedUnion("kind", [ kind: z.literal("question"), unitId: z.uuid(), promptVi: z.string().trim().min(2).max(3000), + promptEn: z.string().trim().max(3000).optional(), + promptJa: z.string().trim().max(3000).optional(), + promptTh: z.string().trim().max(3000).optional(), explanationVi: z.string().trim().max(3000).optional(), + explanationEn: z.string().trim().max(3000).optional(), + explanationJa: z.string().trim().max(3000).optional(), + explanationTh: z.string().trim().max(3000).optional(), passage: z.string().trim().max(10000).optional(), audioUrl: z.string().trim().url().optional().or(z.literal("")), skill, @@ -55,7 +72,13 @@ const createSchema = z.discriminatedUnion("kind", [ kind: z.literal("challenge"), slug: z.string().trim().min(2).max(80).regex(/^[a-z0-9-]+$/), titleVi: z.string().trim().min(2).max(160), + titleEn: z.string().trim().max(160).optional(), + titleJa: z.string().trim().max(160).optional(), + titleTh: z.string().trim().max(160).optional(), descriptionVi: z.string().trim().max(1000), + descriptionEn: z.string().trim().max(1000).optional(), + descriptionJa: z.string().trim().max(1000).optional(), + descriptionTh: z.string().trim().max(1000).optional(), challengeType: z.enum(["daily", "weekly", "boss", "community"]), difficulty: z.enum(["easy", "normal", "hard", "legendary"]), eventType: z.string().trim().max(80).optional(), @@ -89,8 +112,8 @@ export async function POST(request: Request) { if (input.kind === "path") { result = await admin.from("learning_paths").insert({ slug: input.slug, - title: { vi: input.titleVi, en: input.titleEn || input.titleVi }, - description: { vi: input.descriptionVi || "" }, + title: { vi: input.titleVi, en: input.titleEn || input.titleVi, ja: input.titleJa || input.titleEn || input.titleVi, th: input.titleTh || input.titleEn || input.titleVi }, + description: { vi: input.descriptionVi || "", en: input.descriptionEn || input.descriptionVi || "", ja: input.descriptionJa || input.descriptionEn || input.descriptionVi || "", th: input.descriptionTh || input.descriptionEn || input.descriptionVi || "" }, target_level: input.targetLevel, estimated_hours: input.estimatedHours, is_published: input.published, @@ -100,8 +123,8 @@ export async function POST(request: Request) { result = await admin.from("learning_units").insert({ path_id: input.pathId, position: input.position, - title: { vi: input.titleVi, en: input.titleVi }, - description: { vi: input.descriptionVi || "" }, + title: { vi: input.titleVi, en: input.titleEn || input.titleVi, ja: input.titleJa || input.titleEn || input.titleVi, th: input.titleTh || input.titleEn || input.titleVi }, + description: { vi: input.descriptionVi || "", en: input.descriptionEn || input.descriptionVi || "", ja: input.descriptionJa || input.descriptionEn || input.descriptionVi || "", th: input.descriptionTh || input.descriptionEn || input.descriptionVi || "" }, skill: input.skill, level: input.level, estimated_minutes: input.estimatedMinutes, @@ -133,8 +156,8 @@ export async function POST(request: Request) { owner_id: null, skill: input.skill, question_type: input.questionType, - prompt: { vi: input.promptVi, en: input.promptVi }, - explanation: { vi: input.explanationVi || "" }, + prompt: { vi: input.promptVi, en: input.promptEn || input.promptVi, ja: input.promptJa || input.promptEn || input.promptVi, th: input.promptTh || input.promptEn || input.promptVi }, + explanation: { vi: input.explanationVi || "", en: input.explanationEn || input.explanationVi || "", ja: input.explanationJa || input.explanationEn || input.explanationVi || "", th: input.explanationTh || input.explanationEn || input.explanationVi || "" }, passage: input.passage || null, audio_url: input.audioUrl || null, options: optionObjects.length ? optionObjects : null, @@ -148,8 +171,8 @@ export async function POST(request: Request) { ends.setDate(ends.getDate() + input.durationDays); result = await admin.from("learning_challenges").insert({ slug: input.slug, - title: { vi: input.titleVi, en: input.titleVi }, - description: { vi: input.descriptionVi, en: input.descriptionVi }, + title: { vi: input.titleVi, en: input.titleEn || input.titleVi, ja: input.titleJa || input.titleEn || input.titleVi, th: input.titleTh || input.titleEn || input.titleVi }, + description: { vi: input.descriptionVi, en: input.descriptionEn || input.descriptionVi, ja: input.descriptionJa || input.descriptionEn || input.descriptionVi, th: input.descriptionTh || input.descriptionEn || input.descriptionVi }, challenge_type: input.challengeType, difficulty: input.difficulty, event_type: input.eventType || null, diff --git a/src/app/api/practice/attempt/route.ts b/src/app/api/practice/attempt/route.ts index f704fe4..a32f2fc 100644 --- a/src/app/api/practice/attempt/route.ts +++ b/src/app/api/practice/attempt/route.ts @@ -1,6 +1,12 @@ import { NextResponse } from "next/server"; import { z } from "zod"; import { getOptionalViewer } from "@/lib/auth"; +import { + answerWords, + normalizeAnswer, + orderedSentenceScore, + wordSimilarity, +} from "@/lib/practice-scoring"; import { createAdminClient } from "@/lib/supabase/admin"; const schema = z.object({ @@ -12,31 +18,6 @@ const schema = z.object({ idempotencyKey: z.uuid(), }); -function normalize(value: unknown) { - return String(value ?? "").trim().toLocaleLowerCase("en"); -} - -function words(value: unknown) { - return normalize(value) - .replace(/[^\p{L}\p{N}\s]/gu, "") - .split(/\s+/) - .filter(Boolean); -} - -function similarity(expectedValue: unknown, actualValue: unknown) { - const expected = words(expectedValue); - const actual = words(actualValue); - if (!expected.length) return 0; - const remaining = [...actual]; - const matched = expected.filter((word) => { - const index = remaining.indexOf(word); - if (index < 0) return false; - remaining.splice(index, 1); - return true; - }).length; - return Math.round((matched / expected.length) * 100); -} - export async function POST(request: Request) { const viewer = await getOptionalViewer(); if (!viewer) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); @@ -44,12 +25,51 @@ export async function POST(request: Request) { const admin = createAdminClient(); const { data: question, error: questionError } = await admin .from("practice_questions") - .select("id,skill,question_type,answer_key,explanation,options") + .select("id,unit_id,skill,question_type,answer_key,explanation,options") .eq("id", input.questionId) .single(); if (questionError || !question) { return NextResponse.json({ error: "Không tìm thấy câu hỏi." }, { status: 404 }); } + if (question.unit_id && input.module !== "competition") { + const { data: unit } = await admin + .from("learning_units") + .select("path_id,position") + .eq("id", question.unit_id) + .maybeSingle(); + if (unit) { + const [{ data: enrollment }, { data: previous }] = await Promise.all([ + admin + .from("user_path_enrollments") + .select("id") + .eq("user_id", viewer.id) + .eq("path_id", unit.path_id) + .maybeSingle(), + admin + .from("learning_units") + .select("id") + .eq("path_id", unit.path_id) + .lt("position", unit.position) + .order("position", { ascending: false }) + .limit(1) + .maybeSingle(), + ]); + if (enrollment && previous) { + const { data: previousProgress } = await admin + .from("user_unit_progress") + .select("completed_at") + .eq("user_id", viewer.id) + .eq("unit_id", previous.id) + .maybeSingle(); + if (!previousProgress?.completed_at) { + return NextResponse.json( + { error: "Hãy hoàn thành checkpoint trước để mở bài học này." }, + { status: 403 }, + ); + } + } + } + } const key = (question.answer_key ?? {}) as { value?: unknown; @@ -61,37 +81,39 @@ export async function POST(request: Request) { const options = (question.options ?? []) as Array<{ id?: string; text?: string }>; let score: number | null = null; if (question.question_type === "multiple_choice" || question.question_type === "true_false") { - const selected = options.find((option) => normalize(option.id) === normalize(input.answer)); + const selected = options.find((option) => normalizeAnswer(option.id) === normalizeAnswer(input.answer)); score = - normalize(input.answer) === normalize(key.value) || - normalize(selected?.text) === normalize(key.value) + normalizeAnswer(input.answer) === normalizeAnswer(key.value) || + normalizeAnswer(selected?.text) === normalizeAnswer(key.value) ? 100 : 0; } else if (question.question_type === "dictation") { - score = similarity(key.text ?? key.value, input.answer); + score = wordSimilarity(key.text ?? key.value, input.answer); } else if (question.question_type === "short_answer") { - score = similarity(key.value ?? key.text, input.answer); + score = wordSimilarity(key.value ?? key.text, input.answer); } else if (question.question_type === "fill_blank") { - score = normalize(key.value ?? key.text) === normalize(input.answer) ? 100 : similarity(key.value ?? key.text, input.answer); + score = normalizeAnswer(key.value ?? key.text) === normalizeAnswer(input.answer) + ? 100 + : wordSimilarity(key.value ?? key.text, input.answer); } else if (question.question_type === "match_meaning") { - const selected = options.find((option) => normalize(option.id) === normalize(input.answer)); + const selected = options.find((option) => normalizeAnswer(option.id) === normalizeAnswer(input.answer)); score = - normalize(input.answer) === normalize(key.value) || - normalize(selected?.text) === normalize(key.value) + normalizeAnswer(input.answer) === normalizeAnswer(key.value) || + normalizeAnswer(selected?.text) === normalizeAnswer(key.value) ? 100 : 0; } else if (question.question_type === "sentence_order") { const ordered = Array.isArray(input.answer) ? input.answer.join(" ") : input.answer; - score = similarity(key.text ?? key.value, ordered); + score = orderedSentenceScore(key.text ?? key.value, ordered); } else if (question.question_type === "speaking") { const keywords = key.keywords ?? []; score = keywords.length - ? similarity(keywords.join(" "), input.answer) - : Math.min(100, words(input.answer).length * 10); + ? wordSimilarity(keywords.join(" "), input.answer) + : Math.min(100, answerWords(input.answer).length * 10); } else if (question.question_type === "essay") { - const count = words(input.answer).length; + const count = answerWords(input.answer).length; const minimum = key.min_words ?? 60; const maximum = key.max_words ?? 180; score = @@ -147,5 +169,15 @@ export async function POST(request: Request) { }, rewardEligible: secured.reward_eligible, cooldownSeconds: secured.cooldown_seconds, + correctAnswer: + question.question_type === "multiple_choice" || + question.question_type === "true_false" || + question.question_type === "match_meaning" + ? options.find( + (option) => + normalizeAnswer(option.id) === normalizeAnswer(key.value) || + normalizeAnswer(option.text) === normalizeAnswer(key.value), + )?.text ?? String(key.value ?? "") + : String(key.text ?? key.value ?? ""), }); } diff --git a/src/app/globals.css b/src/app/globals.css index 57064ff..db84247 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -201,6 +201,18 @@ border-top: 4px dotted rgb(125 211 252 / 75%); } + .journey-route path:last-child { + animation: journey-dash 14s linear infinite; + } + + .journey-cloud { + animation: journey-cloud 5s ease-in-out infinite; + } + + .sticker-pop { + animation: sticker-pop 2.4s ease-in-out infinite; + } + html[data-motion="reduced"] *, html[data-motion="reduced"] *::before, html[data-motion="reduced"] *::after { @@ -239,3 +251,27 @@ transform: translateY(0); } } + +@keyframes journey-dash { + to { + stroke-dashoffset: -120; + } +} + +@keyframes journey-cloud { + 0%, 100% { + transform: translateX(0) translateY(0); + } + 50% { + transform: translateX(8px) translateY(-3px); + } +} + +@keyframes sticker-pop { + 0%, 100% { + transform: translateY(0) rotate(-3deg); + } + 50% { + transform: translateY(-4px) rotate(4deg); + } +} diff --git a/src/app/learn/[slug]/page.tsx b/src/app/learn/[slug]/page.tsx index 843a574..d9b8186 100644 --- a/src/app/learn/[slug]/page.tsx +++ b/src/app/learn/[slug]/page.tsx @@ -1,4 +1,11 @@ +import Link from "next/link"; +import { LockKeyhole } from "lucide-react"; import { notFound, redirect } from "next/navigation"; +import { LearningWorkspace } from "@/components/learning-workspace"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent } from "@/components/ui/card"; +import { requireViewer } from "@/lib/auth"; +import { getUnitLessonData } from "@/lib/learning-data"; type Props = { params: Promise<{ slug: string }>; @@ -10,6 +17,38 @@ export default async function LearnPage({ params }: Props) { "documents", "vocabulary", "flashcards", "reading", "listening", "speaking", "writing", "translation", "quiz", "progress", ]); - if (!supported.has(slug)) notFound(); - redirect(`/${slug}`); + if (supported.has(slug)) redirect(`/${slug}`); + if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(slug)) { + notFound(); + } + const viewer = await requireViewer(); + const data = await getUnitLessonData(viewer, slug); + if (!data) notFound(); + if (data.locked) { + return ( + + + + + +

Checkpoint chưa được mở khóa

+

+ {data.reason === "not_enrolled" + ? "Đăng ký lộ trình trước khi bắt đầu bài học." + : "Đạt mastery của checkpoint trước để tiếp tục."} +

+ +
+
+ ); + } + return ( + + ); } diff --git a/src/app/loading.tsx b/src/app/loading.tsx new file mode 100644 index 0000000..a749329 --- /dev/null +++ b/src/app/loading.tsx @@ -0,0 +1,21 @@ +import { Skeleton } from "@/components/ui/skeleton"; + +export default function Loading() { + return ( +
+ +
+ {Array.from({ length: 4 }, (_, index) => ( + + ))} +
+
+ +
+ + +
+
+
+ ); +} diff --git a/src/components/admin-content-studio.tsx b/src/components/admin-content-studio.tsx index ebcafe3..718bf33 100644 --- a/src/components/admin-content-studio.tsx +++ b/src/components/admin-content-studio.tsx @@ -208,36 +208,50 @@ function CreatePathForm({ saving, onCreate }: { saving: boolean; onCreate: (valu event.preventDefault(); const data = new FormData(event.currentTarget); onCreate({ - slug: data.get("slug"), titleVi: data.get("titleVi"), titleEn: data.get("titleEn"), - descriptionVi: data.get("descriptionVi"), targetLevel: data.get("targetLevel"), + slug: data.get("slug"), ...localizedForm(data, "title"), ...localizedForm(data, "description"), + targetLevel: data.get("targetLevel"), estimatedHours: data.get("estimatedHours"), published: data.get("published") === "on", }); } - return