417 lines
19 KiB
TypeScript
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ả, và 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 gì?</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> và 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>
|
|
)
|
|
}
|