phase 2
This commit is contained in:
5
src/hooks/use-auth.ts
Normal file
5
src/hooks/use-auth.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { useAuthStore } from '@/store/auth-store'
|
||||
|
||||
export const useAuth = () => useAuthStore()
|
||||
export const useUser = () => useAuthStore((s) => s.user)
|
||||
export const useIsAuthenticated = () => useAuthStore((s) => s.user !== null)
|
||||
@@ -1,18 +1,35 @@
|
||||
import { useQuery } from "@tanstack/react-query"
|
||||
import { supabase } from "@/lib/supabase"
|
||||
import type { Question } from "@/types"
|
||||
|
||||
const ANSWER_INDEX: Record<string, number> = { A: 0, B: 1, C: 2, D: 3 }
|
||||
|
||||
// Maps a Supabase row to the shared Question interface.
|
||||
// DB uses `content` + `answer` ('A'–'D'); interface uses `text` + `correctAnswer` (0–3).
|
||||
function rowToQuestion(row: Record<string, unknown>): Question {
|
||||
return {
|
||||
id: row.id as string,
|
||||
part: row.part as number,
|
||||
text: row.content as string,
|
||||
options: row.options as string[],
|
||||
correctAnswer: ANSWER_INDEX[(row.answer as string).toUpperCase()] ?? 0,
|
||||
explanation: (row.explanation as string) ?? '',
|
||||
}
|
||||
}
|
||||
|
||||
// Exported for imperative use (e.g. ToeicPractice click handler).
|
||||
// part=0 fetches all parts (Full Test).
|
||||
export async function fetchQuestions(part: number, limit = 10): Promise<Question[]> {
|
||||
let query = supabase.from('questions').select('*').limit(limit)
|
||||
if (part > 0) query = query.eq('part', part)
|
||||
const { data, error } = await query
|
||||
if (error) throw error
|
||||
return (data ?? []).map(rowToQuestion)
|
||||
}
|
||||
|
||||
export function useQuestions(part: number, limit = 10) {
|
||||
return useQuery({
|
||||
queryKey: ["questions", part, limit],
|
||||
queryFn: async () => {
|
||||
const { data, error } = await supabase
|
||||
.from("questions")
|
||||
.select("*")
|
||||
.eq("part", part)
|
||||
.limit(limit)
|
||||
if (error) throw error
|
||||
return data
|
||||
},
|
||||
enabled: false, // Enabled during feature implementation
|
||||
queryKey: ['questions', part, limit],
|
||||
queryFn: () => fetchQuestions(part, limit),
|
||||
})
|
||||
}
|
||||
|
||||
17
src/hooks/use-require-auth.ts
Normal file
17
src/hooks/use-require-auth.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { useAuthStore } from '@/store/auth-store'
|
||||
import { useAuthModalStore } from '@/store/auth-modal-store'
|
||||
|
||||
export function useRequireAuth() {
|
||||
const user = useAuthStore((s) => s.user)
|
||||
const isLoading = useAuthStore((s) => s.isLoading)
|
||||
const openModal = useAuthModalStore((s) => s.open)
|
||||
|
||||
/** Returns true if authenticated. If guest, opens auth modal and returns false. */
|
||||
function requireAuth(): boolean {
|
||||
if (user) return true
|
||||
openModal('register')
|
||||
return false
|
||||
}
|
||||
|
||||
return { isAuthenticated: !!user, isLoading, requireAuth }
|
||||
}
|
||||
@@ -1,16 +1,32 @@
|
||||
import { useQuery } from "@tanstack/react-query"
|
||||
import { supabase } from "@/lib/supabase"
|
||||
import type { VocabWord, VocabTopic } from "@/types"
|
||||
|
||||
export function useVocab(topic?: string) {
|
||||
// Maps a Supabase row to VocabWord.
|
||||
// DB column `meaning_vi` → interface field `meaningVi`.
|
||||
function rowToVocabWord(row: Record<string, unknown>): VocabWord {
|
||||
return {
|
||||
id: row.id as string,
|
||||
word: row.word as string,
|
||||
phonetic: (row.phonetic as string) ?? '',
|
||||
meaningVi: row.meaning_vi as string,
|
||||
topic: row.topic as VocabTopic,
|
||||
example: (row.example as string) ?? '',
|
||||
}
|
||||
}
|
||||
|
||||
// Fetches ALL vocab; topic filtering is done in-component so we avoid
|
||||
// separate queries per topic and keep the cache simple.
|
||||
export function useVocab() {
|
||||
return useQuery({
|
||||
queryKey: ["vocab", topic],
|
||||
queryKey: ['vocab'],
|
||||
queryFn: async () => {
|
||||
let query = supabase.from("vocab").select("*")
|
||||
if (topic) query = query.eq("topic", topic.toLowerCase())
|
||||
const { data, error } = await query
|
||||
const { data, error } = await supabase
|
||||
.from('vocab')
|
||||
.select('*')
|
||||
.order('topic')
|
||||
if (error) throw error
|
||||
return data
|
||||
return (data ?? []).map(rowToVocabWord)
|
||||
},
|
||||
enabled: false, // Enabled during feature implementation
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,28 +1,89 @@
|
||||
import { useMutation } from "@tanstack/react-query"
|
||||
import { supabase } from "@/lib/supabase"
|
||||
import { canUseWritingCheck, recordWritingCheckUsage } from "@/utils/rate-limiter"
|
||||
import { useAuthStore } from "@/store/auth-store"
|
||||
import { saveWritingSubmission, countTodayWritingSubmissions } from "@/lib/progress-service"
|
||||
import type { WritingFeedback } from "@/types"
|
||||
|
||||
interface WritingFeedback {
|
||||
score: string
|
||||
grammar: string[]
|
||||
vocabulary: string[]
|
||||
structure: string
|
||||
improved_version: string
|
||||
summary: string
|
||||
const AUTH_DAILY_LIMIT = 10
|
||||
const GUEST_DAILY_LIMIT = 3
|
||||
|
||||
const GLM_BASE_URL = "https://open.bigmodel.cn/api/paas/v4"
|
||||
const GLM_API_KEY = import.meta.env.VITE_GLM_API_KEY as string
|
||||
const GLM_MODEL = (import.meta.env.VITE_GLM_MODEL as string) || "GLM-4-32B-0414-128K"
|
||||
|
||||
// Keep system prompt concise — fewer tokens = more room for output.
|
||||
// improved_version omitted from schema to reduce output length; added back as optional.
|
||||
const SYSTEM_PROMPT = `You are an expert English writing teacher for TOEIC and IELTS.
|
||||
Respond ONLY with valid JSON, no markdown:
|
||||
{"score":"6.5","grammar":["issue + fix in Vietnamese"],"vocabulary":["observation in Vietnamese"],"structure":"2 sentences in Vietnamese","improved_version":"full improved text","summary":"2 sentences in Vietnamese"}`
|
||||
|
||||
async function callGlm(content: string): Promise<WritingFeedback> {
|
||||
const res = await fetch(`${GLM_BASE_URL}/chat/completions`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${GLM_API_KEY}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: GLM_MODEL,
|
||||
messages: [
|
||||
{ role: "system", content: SYSTEM_PROMPT },
|
||||
{ role: "user", content: `Analyse:\n\n${content.slice(0, 1500)}` },
|
||||
],
|
||||
temperature: 0.3,
|
||||
max_tokens: 2500,
|
||||
// Force JSON output mode (OpenAI-compatible, supported by GLM)
|
||||
response_format: { type: "json_object" },
|
||||
}),
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}))
|
||||
throw new Error((err as { error?: { message?: string } }).error?.message ?? `GLM error ${res.status}`)
|
||||
}
|
||||
|
||||
const data = await res.json() as { choices: { message: { content: string } }[] }
|
||||
const raw = data.choices[0]?.message?.content ?? "{}"
|
||||
|
||||
// Strip markdown code fences defensively
|
||||
const cleaned = raw.replace(/^```(?:json)?\s*/i, "").replace(/\s*```$/, "").trim()
|
||||
|
||||
try {
|
||||
return JSON.parse(cleaned) as WritingFeedback
|
||||
} catch {
|
||||
throw new Error("Phản hồi từ AI không hợp lệ. Vui lòng thử lại.")
|
||||
}
|
||||
}
|
||||
|
||||
export function useWritingCheck() {
|
||||
return useMutation({
|
||||
mutationFn: async (content: string): Promise<WritingFeedback> => {
|
||||
if (!canUseWritingCheck()) {
|
||||
throw new Error("Bạn đã dùng hết 3 lần kiểm tra hôm nay. Quay lại vào ngày mai!")
|
||||
const user = useAuthStore.getState().user
|
||||
|
||||
if (user) {
|
||||
// Server-side rate limit for authenticated users (10/day)
|
||||
const usedToday = await countTodayWritingSubmissions(user.id)
|
||||
if (usedToday >= AUTH_DAILY_LIMIT) {
|
||||
throw new Error(`Bạn đã dùng hết ${AUTH_DAILY_LIMIT} lần kiểm tra hôm nay. Quay lại vào ngày mai!`)
|
||||
}
|
||||
} else {
|
||||
// localStorage rate limit for guests (3/day)
|
||||
if (!canUseWritingCheck()) {
|
||||
throw new Error(`Bạn đã dùng hết ${GUEST_DAILY_LIMIT} lần kiểm tra hôm nay. Đăng ký để được 10 lần/ngày!`)
|
||||
}
|
||||
}
|
||||
const { data, error } = await supabase.functions.invoke("writing-check", {
|
||||
body: { content },
|
||||
})
|
||||
if (error) throw error
|
||||
recordWritingCheckUsage()
|
||||
return data as WritingFeedback
|
||||
|
||||
const feedback = await callGlm(content)
|
||||
|
||||
if (user) {
|
||||
// Save to DB (fire-and-forget)
|
||||
saveWritingSubmission(user.id, content, feedback)
|
||||
} else {
|
||||
// Persist guest usage in localStorage
|
||||
recordWritingCheckUsage()
|
||||
}
|
||||
|
||||
return feedback
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user