Files
english/src/features/writing/components/WritingChecker.tsx
2026-04-18 23:16:52 +07:00

417 lines
19 KiB
TypeScript

import { useState, useEffect } from 'react'
import { cn } from '@/lib/utils'
import { useWritingCheck } from '@/hooks/use-writing-check'
import { getRemainingChecks } from '@/utils/rate-limiter'
import { useRequireAuth } from '@/hooks/use-require-auth'
import { useAuthStore } from '@/store/auth-store'
import { countTodayWritingSubmissions } from '@/lib/progress-service'
import { useAwardActivity } from '@/hooks/use-gamification'
import { XP_REWARDS } from '@/lib/gamification-service'
const MAX_CHARS = 1000
const GUEST_LIMIT = 3
const AUTH_LIMIT = 10
// Extract a string field from partial JSON stream
function extractTextField(partial: string, field: string): string {
const m = partial.match(new RegExp(`"${field}"\\s*:\\s*"((?:[^"\\\\]|\\\\.)*)`))
if (!m) return ''
return m[1].replace(/\\n/g, '\n').replace(/\\"/g, '"')
}
// Extract score from partial JSON stream as soon as the field is complete
function extractScore(partial: string): string | null {
const m = partial.match(/"score"\s*:\s*"([^"]+)"/)
return m ? m[1] : null
}
// Extract completed array items from a partial JSON array field
function extractArrayField(partial: string, field: string): string[] {
const m = partial.match(new RegExp(`"${field}"\\s*:\\s*\\[([^\\]]*)`))
if (!m) return []
const items: string[] = []
const re = /"((?:[^"\\]|\\.)*)"/g
let match
while ((match = re.exec(m[1])) !== null) {
items.push(match[1].replace(/\\n/g, '\n').replace(/\\"/g, '"'))
}
return items
}
export function WritingChecker() {
const [text, setText] = useState('')
const [improvedExpanded, setImprovedExpanded] = useState(false)
const [remaining, setRemaining] = useState(getRemainingChecks)
const [streamingText, setStreamingText] = useState('')
const { mutate: checkWriting, isPending, isError, error, data: feedback, reset: resetMutation } = useWritingCheck()
const { requireAuth } = useRequireAuth()
const user = useAuthStore((s) => s.user)
const { mutate: awardActivity } = useAwardActivity()
const dailyLimit = user ? AUTH_LIMIT : GUEST_LIMIT
// Fetch server-side remaining count for authenticated users
useEffect(() => {
if (!user) {
setRemaining(getRemainingChecks())
resetMutation()
return
}
countTodayWritingSubmissions(user.id).then((used) => {
setRemaining(AUTH_LIMIT - used)
})
}, [user, resetMutation])
const streamingScore = isPending ? extractScore(streamingText) : null
const streamingGrammar = isPending ? extractArrayField(streamingText, 'grammar') : []
const streamingVocab = isPending ? extractArrayField(streamingText, 'vocabulary') : []
const streamingStructure = isPending ? extractTextField(streamingText, 'structure') : ''
const streamingSummary = isPending ? extractTextField(streamingText, 'summary') : ''
const charCount = text.length
const canSubmit = text.trim().length > 0 && remaining > 0 && charCount <= MAX_CHARS && !isPending
function handleSubmit() {
if (!requireAuth()) return
if (!canSubmit) return
setStreamingText('')
checkWriting(
{ content: text, onChunk: (chunk) => setStreamingText((prev) => prev + chunk) },
{
onSuccess: () => {
setStreamingText('')
if (user) {
awardActivity({ xp: XP_REWARDS.writing })
countTodayWritingSubmissions(user.id).then((used) => setRemaining(AUTH_LIMIT - used))
} else {
setRemaining(getRemainingChecks())
}
},
onError: () => {
setStreamingText('')
if (!user) setRemaining(getRemainingChecks())
},
},
)
}
const sentenceCount = text.split(/[.!?]+/).filter(s => s.trim()).length
const wordCount = text.split(/\s+/).filter(Boolean).length
return (
<div className="px-6 lg:px-10 py-10 max-w-6xl mx-auto page-enter">
{/* Editorial page head */}
<div className="flex flex-col lg:flex-row lg:items-end lg:justify-between gap-6 mb-10">
<div className="min-w-0">
<div className="at-eyebrow mb-3 inline-flex items-center gap-1.5">
<span className="material-symbols-outlined" style={{ fontSize: 12 }}>auto_awesome</span>
AI Writing Checker
</div>
<h1 className="at-title text-4xl lg:text-[44px]">
Kiểm tra <i>bài viết</i>
</h1>
<p className="mt-4 text-sm" style={{ color: 'var(--at-mute)' }}>
Dán bài viết AI sẽ kiểm tra ngữ pháp, chính tả, chấm điểm IELTS/TOEIC
</p>
</div>
<div className="flex gap-2.5 flex-shrink-0">
<button
className="inline-flex items-center gap-2 px-4 py-3 rounded-xl text-[13.5px] font-semibold transition-colors hover:opacity-80"
style={{ background: 'var(--at-surface)', border: '1px solid var(--at-line)', color: 'var(--at-ink-2)' }}
>
<span className="material-symbols-outlined" style={{ fontSize: 16 }}>mic</span>
Nhập bằng giọng nói
</button>
<button
onClick={handleSubmit}
disabled={!canSubmit}
className={cn(
'inline-flex items-center gap-2 px-5 py-3 rounded-xl text-[13.5px] font-semibold transition-opacity',
canSubmit ? 'hover:opacity-90' : 'cursor-not-allowed opacity-50',
)}
style={{ background: 'var(--at-ink)', color: 'var(--at-paper)', border: '1px solid var(--at-ink)' }}
>
{isPending ? (
<>
<span className="w-4 h-4 border-2 border-white/40 border-t-white rounded-full animate-spin" />
Đang chấm...
</>
) : (
<>
<span className="material-symbols-outlined" style={{ fontSize: 16 }}>auto_awesome</span>
Kiểm tra ngay
</>
)}
</button>
</div>
</div>
<div className="grid lg:grid-cols-[1.5fr_1fr] gap-5">
{/* Left: Input */}
<div className="min-w-0">
<div
className="rounded-2xl overflow-hidden"
style={{ background: 'var(--at-surface)', border: '1px solid var(--at-line)' }}
>
<div
className="px-5 py-3.5 flex items-center justify-between"
style={{ background: 'var(--at-paper-2)', borderBottom: '1px solid var(--at-line)' }}
>
<div className="flex gap-1.5">
<span className="at-chip">
<span className="at-chip-dot" />
Đề: Working from home
</span>
<span className="at-chip at-chip-brand">
<span className="at-chip-dot" />
Essay · Band 6-7
</span>
</div>
<div className="text-xs tabular-nums" style={{ color: charCount > MAX_CHARS ? 'var(--at-bad)' : 'var(--at-mute)' }}>
{wordCount} từ · {sentenceCount} câu · {charCount}/{MAX_CHARS}
</div>
</div>
<div className="p-5">
<textarea
value={text}
onChange={(e) => setText(e.target.value.slice(0, MAX_CHARS))}
rows={12}
dir="ltr"
placeholder="Bắt đầu viết hoặc dán bài của bạn ở đây..."
className="w-full resize-none bg-transparent border-none outline-none"
style={{
fontFamily: 'var(--at-sans)',
fontSize: 15,
lineHeight: 1.7,
color: 'var(--at-ink)',
minHeight: 280,
}}
/>
<div className="mt-3 flex items-center gap-1.5">
<span className="material-symbols-outlined" style={{ fontSize: 14, color: 'var(--at-mute)' }}>info</span>
<span className="text-xs font-medium" style={{ color: remaining <= 1 ? 'var(--at-bad)' : 'var(--at-mute)' }}>
Còn {remaining}/{dailyLimit} lượt hôm nay
</span>
</div>
</div>
</div>
{remaining <= 0 && (
<div className="mt-3 bg-amber-50 border border-amber-100 rounded-xl p-4 flex items-center gap-3">
<span className="material-symbols-outlined text-amber-600 flex-shrink-0" style={{ fontSize: 20 }}>schedule</span>
<p className="text-sm text-amber-700">
Bạn đã dùng hết {dailyLimit} lượt hôm nay. Vui lòng quay lại vào ngày mai.
</p>
</div>
)}
{isError && (
<div className="mt-3 bg-red-50 border border-red-100 rounded-xl p-4 flex items-center gap-3">
<span className="material-symbols-outlined text-red-500 flex-shrink-0" style={{ fontSize: 20 }}>error</span>
<p className="text-sm text-red-600">
{(error as Error)?.message ?? 'Đã có lỗi xảy ra. Vui lòng thử lại.'}
</p>
</div>
)}
</div>
{/* Right: Feedback */}
<div className="flex flex-col gap-5">
{!feedback && !isPending && (
<div className="at-tip">
<div className="at-tip-label">AI kiểm tra ?</div>
<div className="text-[12.5px] leading-[1.55]" style={{ color: 'var(--at-ink-2)' }}>
Ngữ pháp · Chính tả · Từ vựng học thuật · Tính mạch lạc · Chấm điểm theo band IELTS/TOEIC.
Một bài TOEIC Writing band 7+ cần{' '}
<b style={{ color: 'var(--at-warm)', fontWeight: 700 }}>ít nhất 250 từ</b> sử dụng{' '}
<b style={{ color: 'var(--at-warm)', fontWeight: 700 }}>3-4 linking words</b>.
</div>
</div>
)}
{isPending && (
<div className="space-y-3">
{/* Score */}
<div className="bg-blue-600 rounded-2xl p-5 text-center">
<div className="text-xs text-blue-200 font-medium mb-1 uppercase tracking-wider">Band Score ưc tính</div>
{streamingScore ? (
<div className="text-5xl font-extrabold text-white mb-1">{streamingScore}</div>
) : (
<div className="h-12 w-20 mx-auto bg-blue-500/40 rounded-xl animate-pulse mb-1" />
)}
<div className="text-xs text-blue-200">Dựa trên tiêu chí IELTS/TOEIC Writing</div>
</div>
{/* Grammar */}
<div className="bg-white rounded-2xl border border-slate-200 p-4">
<div className="flex items-center gap-2 mb-3">
<div className="w-2 h-2 rounded-full bg-red-500" />
<span className="text-sm font-bold text-slate-800">Ngữ pháp</span>
</div>
{streamingGrammar.length > 0 ? (
<ul className="space-y-1.5">
{streamingGrammar.map((item, i) => (
<li key={i} className="text-xs text-slate-600 flex items-start gap-2">
<span className="material-symbols-outlined text-red-400 flex-shrink-0 mt-0.5" style={{ fontSize: 14 }}>error</span>
{item}
</li>
))}
</ul>
) : (
<div className="space-y-2">
{[78, 92, 65].map((w, i) => (
<div key={i} className="flex items-start gap-2">
<div className="w-3.5 h-3.5 mt-0.5 rounded-full bg-slate-100 animate-pulse flex-shrink-0" />
<div className="h-3 bg-slate-100 rounded animate-pulse" style={{ width: `${w}%` }} />
</div>
))}
</div>
)}
</div>
{/* Vocabulary */}
<div className="bg-white rounded-2xl border border-slate-200 p-4">
<div className="flex items-center gap-2 mb-3">
<div className="w-2 h-2 rounded-full bg-amber-500" />
<span className="text-sm font-bold text-slate-800">Từ vựng</span>
</div>
{streamingVocab.length > 0 ? (
<ul className="space-y-1.5">
{streamingVocab.map((item, i) => (
<li key={i} className="text-xs text-slate-600 flex items-start gap-2">
<span className="material-symbols-outlined text-amber-400 flex-shrink-0 mt-0.5" style={{ fontSize: 14 }}>lightbulb</span>
{item}
</li>
))}
</ul>
) : (
<div className="h-3 bg-slate-100 rounded animate-pulse w-4/5" />
)}
</div>
{/* Structure */}
<div className="bg-white rounded-2xl border border-slate-200 p-4">
<div className="flex items-center gap-2 mb-2">
<div className="w-2 h-2 rounded-full bg-blue-500" />
<span className="text-sm font-bold text-slate-800">Cấu trúc</span>
</div>
{streamingStructure ? (
<p className="text-xs text-slate-600">{streamingStructure}</p>
) : (
<div className="space-y-1.5">
<div className="h-3 bg-slate-100 rounded animate-pulse" />
<div className="h-3 bg-slate-100 rounded animate-pulse w-5/6" />
<div className="h-3 bg-slate-100 rounded animate-pulse w-4/6" />
</div>
)}
</div>
{/* Summary */}
<div className="bg-green-50 rounded-2xl border border-green-100 p-4">
<div className="flex items-center gap-2 mb-2">
{streamingSummary ? (
<span className="material-symbols-outlined text-green-600" style={{ fontSize: 16 }}>summarize</span>
) : (
<div className="w-4 h-4 bg-green-200 rounded animate-pulse" />
)}
{streamingSummary ? (
<span className="text-sm font-bold text-green-700">Tổng nhận xét</span>
) : (
<div className="h-4 w-24 bg-green-200 rounded animate-pulse" />
)}
</div>
{streamingSummary ? (
<p className="text-xs text-slate-600">{streamingSummary}</p>
) : (
<div className="space-y-1.5">
<div className="h-3 bg-green-100 rounded animate-pulse" />
<div className="h-3 bg-green-100 rounded animate-pulse w-3/4" />
</div>
)}
</div>
</div>
)}
{feedback && !isPending && (
<div className="space-y-3">
{/* Band score */}
<div className="bg-blue-600 rounded-2xl p-5 text-center">
<div className="text-xs text-blue-200 font-medium mb-1 uppercase tracking-wider">Band Score ưc tính</div>
<div className="text-5xl font-extrabold text-white mb-1">{feedback.score}</div>
<div className="text-xs text-blue-200">Dựa trên tiêu chí IELTS/TOEIC Writing</div>
</div>
{/* Grammar */}
<div className="bg-white rounded-2xl border border-slate-200 p-4">
<div className="flex items-center gap-2 mb-3">
<div className="w-2 h-2 rounded-full bg-red-500" />
<span className="text-sm font-bold text-slate-800">Ngữ pháp</span>
</div>
<ul className="space-y-1.5">
{feedback.grammar.map((item, i) => (
<li key={i} className="text-xs text-slate-600 flex items-start gap-2">
<span className="material-symbols-outlined text-red-400 flex-shrink-0 mt-0.5" style={{ fontSize: 14 }}>error</span>
{item}
</li>
))}
</ul>
</div>
{/* Vocabulary */}
<div className="bg-white rounded-2xl border border-slate-200 p-4">
<div className="flex items-center gap-2 mb-3">
<div className="w-2 h-2 rounded-full bg-amber-500" />
<span className="text-sm font-bold text-slate-800">Từ vựng</span>
</div>
<ul className="space-y-1.5">
{feedback.vocabulary.map((item, i) => (
<li key={i} className="text-xs text-slate-600 flex items-start gap-2">
<span className="material-symbols-outlined text-amber-400 flex-shrink-0 mt-0.5" style={{ fontSize: 14 }}>lightbulb</span>
{item}
</li>
))}
</ul>
</div>
{/* Structure */}
<div className="bg-white rounded-2xl border border-slate-200 p-4">
<div className="flex items-center gap-2 mb-2">
<div className="w-2 h-2 rounded-full bg-blue-500" />
<span className="text-sm font-bold text-slate-800">Cấu trúc</span>
</div>
<p className="text-xs text-slate-600">{feedback.structure}</p>
</div>
{/* Improved version */}
<div className="bg-white rounded-2xl border border-slate-200 p-4">
<button
onClick={() => setImprovedExpanded((v) => !v)}
className="w-full flex items-center justify-between"
>
<div className="flex items-center gap-2">
<div className="w-2 h-2 rounded-full bg-green-500" />
<span className="text-sm font-bold text-slate-800">Bài viết cải thiện</span>
</div>
<span className="material-symbols-outlined text-slate-400" style={{ fontSize: 18 }}>
{improvedExpanded ? 'expand_less' : 'expand_more'}
</span>
</button>
{improvedExpanded && (
<p className="mt-3 text-xs text-slate-600 leading-relaxed border-t border-slate-100 pt-3">
{feedback.improvedVersion}
</p>
)}
</div>
{/* Summary */}
<div className="bg-green-50 rounded-2xl border border-green-100 p-4">
<div className="flex items-center gap-2 mb-2">
<span className="material-symbols-outlined text-green-600" style={{ fontSize: 16 }}>summarize</span>
<span className="text-sm font-bold text-green-700">Tổng nhận xét</span>
</div>
<p className="text-xs text-slate-600">{feedback.summary}</p>
</div>
</div>
)}
</div>
</div>
</div>
)
}