diff --git a/src/App.jsx b/src/App.jsx
index f8229b3..b9ac263 100644
--- a/src/App.jsx
+++ b/src/App.jsx
@@ -38,6 +38,7 @@ import DailyLearning from './domains/vocab/pages/DailyLearning'
import TestPage from './domains/vocab/pages/TestPage'
import WordListPage from './domains/vocab/pages/WordListPage'
import StatsPage from './domains/vocab/pages/StatsPage'
+import { WritingPage } from './domains/grammar'
import { useChat } from './contexts/ChatContext'
import { useSettings } from './contexts/SettingsContext'
@@ -334,14 +335,6 @@ function FreetalkAiPage() {
)
}
-function WritingPage() {
- return (
-
- Writing Practice
- Grammar correction & feedback
-
- )
-}
function ReportsPage() {
return (
diff --git a/src/api/grammarApi.js b/src/api/grammarApi.js
new file mode 100644
index 0000000..5a1029c
--- /dev/null
+++ b/src/api/grammarApi.js
@@ -0,0 +1,40 @@
+import axios from 'axios'
+
+const grammarApi = axios.create({
+ baseURL: import.meta.env.VITE_GRAMMAR_API_URL || import.meta.env.VITE_API_URL,
+ timeout: 30000,
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+})
+
+// Request interceptor
+grammarApi.interceptors.request.use(
+ (config) => {
+ const token = localStorage.getItem('accessToken')
+ if (token) {
+ config.headers.Authorization = `Bearer ${token}`
+ }
+ return config
+ },
+ (error) => {
+ return Promise.reject(error)
+ }
+)
+
+// Response interceptor
+grammarApi.interceptors.response.use(
+ (response) => response.data,
+ (error) => {
+ console.error('Grammar API Error:', error.response?.data || error.message)
+
+ if (error.response?.status === 401) {
+ localStorage.removeItem('accessToken')
+ window.location.href = '/login'
+ }
+
+ return Promise.reject(error)
+ }
+)
+
+export default grammarApi
diff --git a/src/domains/grammar/components/ChatInput.jsx b/src/domains/grammar/components/ChatInput.jsx
new file mode 100644
index 0000000..d1386aa
--- /dev/null
+++ b/src/domains/grammar/components/ChatInput.jsx
@@ -0,0 +1,212 @@
+import { useState } from 'react'
+import {
+ Box,
+ TextField,
+ IconButton,
+ Select,
+ MenuItem,
+ FormControl,
+ CircularProgress,
+ Tooltip,
+} from '@mui/material'
+import { Send as SendIcon, School as SchoolIcon } from '@mui/icons-material'
+import { useSettings } from '../../../contexts/SettingsContext'
+import {
+ GRAMMAR_LEVELS,
+ GRAMMAR_LEVEL_COLORS,
+ TEXT_LIMITS,
+} from '../constants/grammarConstants'
+
+export default function ChatInput({ onSend, loading = false, level, onLevelChange }) {
+ const { t, isKorean } = useSettings()
+ const [message, setMessage] = useState('')
+
+ const handleSend = () => {
+ if (message.trim().length < TEXT_LIMITS.MIN_SENTENCE_LENGTH || loading) return
+ onSend?.(message.trim())
+ setMessage('')
+ }
+
+ const handleKeyDown = (e) => {
+ if (e.key === 'Enter' && !e.shiftKey) {
+ e.preventDefault()
+ handleSend()
+ }
+ }
+
+ const canSend = message.trim().length >= TEXT_LIMITS.MIN_SENTENCE_LENGTH && !loading
+
+ const levelOptions = [
+ { value: GRAMMAR_LEVELS.BEGINNER, label: isKorean ? '초급' : 'Beginner' },
+ { value: GRAMMAR_LEVELS.INTERMEDIATE, label: isKorean ? '중급' : 'Intermediate' },
+ { value: GRAMMAR_LEVELS.ADVANCED, label: isKorean ? '고급' : 'Advanced' },
+ ]
+
+ return (
+
+
+ {/* Level Selector */}
+
+
+
+
+
+
+ {/* Text Input */}
+ setMessage(e.target.value)}
+ onKeyDown={handleKeyDown}
+ placeholder={
+ isKorean
+ ? '영어 문장을 입력하세요...'
+ : 'Type your English sentence...'
+ }
+ disabled={loading}
+ sx={{
+ '& .MuiOutlinedInput-root': {
+ borderRadius: '16px',
+ backgroundColor: '#f9fafb',
+ '&:hover': {
+ backgroundColor: '#f3f4f6',
+ },
+ '&.Mui-focused': {
+ backgroundColor: '#fff',
+ },
+ '& fieldset': {
+ borderColor: '#e5e7eb',
+ },
+ '&:hover fieldset': {
+ borderColor: '#d1d5db',
+ },
+ '&.Mui-focused fieldset': {
+ borderColor: GRAMMAR_LEVEL_COLORS[level],
+ },
+ },
+ '& .MuiInputBase-input': {
+ py: 1.5,
+ px: 2,
+ '&::placeholder': {
+ color: '#9ca3af',
+ opacity: 1,
+ },
+ },
+ }}
+ />
+
+ {/* Send Button */}
+
+ {loading ? (
+
+ ) : (
+
+ )}
+
+
+
+ {/* Character count hint */}
+
+ TEXT_LIMITS.MAX_SENTENCE_LENGTH
+ ? '#ef4444'
+ : '#9ca3af',
+ }}
+ >
+ {message.length > 0 && `${message.length}/${TEXT_LIMITS.MAX_SENTENCE_LENGTH}`}
+
+
+
+ )
+}
diff --git a/src/domains/grammar/components/ChatMessage.jsx b/src/domains/grammar/components/ChatMessage.jsx
new file mode 100644
index 0000000..22454e8
--- /dev/null
+++ b/src/domains/grammar/components/ChatMessage.jsx
@@ -0,0 +1,255 @@
+import { Box, Typography, Chip, Collapse, IconButton } from '@mui/material'
+import {
+ SmartToy as AiIcon,
+ Person as PersonIcon,
+ ExpandMore as ExpandMoreIcon,
+ CheckCircle as CheckIcon,
+ Error as ErrorIcon,
+} from '@mui/icons-material'
+import { useState } from 'react'
+import { useSettings } from '../../../contexts/SettingsContext'
+import {
+ GRAMMAR_ERROR_COLORS,
+ GRAMMAR_ERROR_BG_COLORS,
+ getScoreColor,
+} from '../constants/grammarConstants'
+
+export default function ChatMessage({ message, isUser = false }) {
+ const { t, isKorean } = useSettings()
+ const [showDetails, setShowDetails] = useState(false)
+
+ const {
+ content,
+ correctedContent,
+ grammarScore,
+ errors = [],
+ aiResponse,
+ conversationTip,
+ } = message
+
+ const hasErrors = errors && errors.length > 0
+ const scoreColor = grammarScore ? getScoreColor(grammarScore) : '#059669'
+
+ if (isUser) {
+ return (
+
+
+ {/* User Message Bubble */}
+
+
+ {content}
+
+
+
+ {/* Grammar Score Badge */}
+ {grammarScore !== undefined && (
+
+ {grammarScore >= 90 ? (
+
+ ) : (
+
+ )}
+
+ {grammarScore}
+
+ {hasErrors && (
+ setShowDetails(!showDetails)}
+ sx={{ p: 0.25 }}
+ >
+
+
+ )}
+
+ )}
+
+ {/* Correction Details */}
+
+
+ {/* Corrected Sentence */}
+ {correctedContent && correctedContent !== content && (
+
+
+ {t('grammar.corrected')}
+
+
+ {correctedContent}
+
+
+ )}
+
+ {/* Errors */}
+
+ {errors.map((error, idx) => (
+
+
+
+
+
+ {error.original}
+
+ {' → '}
+
+ {error.corrected}
+
+
+
+
+ {error.explanation}
+
+
+ ))}
+
+
+
+
+
+ {/* User Avatar */}
+
+
+
+
+ )
+ }
+
+ // AI Message
+ return (
+
+ {/* AI Avatar */}
+
+
+
+
+
+ {/* AI Message Bubble */}
+
+
+ {aiResponse || content}
+
+
+
+ {/* Conversation Tip */}
+ {conversationTip && (
+
+
+ 💡 {conversationTip}
+
+
+ )}
+
+
+ )
+}
diff --git a/src/domains/grammar/components/GrammarInput.jsx b/src/domains/grammar/components/GrammarInput.jsx
new file mode 100644
index 0000000..da64a2d
--- /dev/null
+++ b/src/domains/grammar/components/GrammarInput.jsx
@@ -0,0 +1,288 @@
+import { useState } from 'react'
+import {
+ Box,
+ TextField,
+ Button,
+ ToggleButton,
+ ToggleButtonGroup,
+ Typography,
+ CircularProgress,
+ Paper,
+} from '@mui/material'
+import {
+ Spellcheck as SpellcheckIcon,
+ School as SchoolIcon,
+} from '@mui/icons-material'
+import { useSettings } from '../../../contexts/SettingsContext'
+import {
+ GRAMMAR_LEVELS,
+ GRAMMAR_LEVEL_COLORS,
+ GRAMMAR_LEVEL_BG_COLORS,
+ TEXT_LIMITS,
+} from '../constants/grammarConstants'
+
+export default function GrammarInput({ onCheck, loading = false }) {
+ const { t, isKorean } = useSettings()
+ const [sentence, setSentence] = useState('')
+ const [level, setLevel] = useState(GRAMMAR_LEVELS.BEGINNER)
+ const [error, setError] = useState('')
+
+ const handleSentenceChange = (e) => {
+ const value = e.target.value
+ setSentence(value)
+
+ if (value.length > TEXT_LIMITS.MAX_SENTENCE_LENGTH) {
+ setError(t('grammar.maxLength'))
+ } else {
+ setError('')
+ }
+ }
+
+ const handleLevelChange = (_, newLevel) => {
+ if (newLevel) {
+ setLevel(newLevel)
+ }
+ }
+
+ const handleCheck = () => {
+ if (sentence.trim().length < TEXT_LIMITS.MIN_SENTENCE_LENGTH) {
+ setError(t('grammar.minLength'))
+ return
+ }
+
+ if (sentence.length > TEXT_LIMITS.MAX_SENTENCE_LENGTH) {
+ setError(t('grammar.maxLength'))
+ return
+ }
+
+ setError('')
+ onCheck?.(sentence.trim(), level)
+ }
+
+ const handleKeyDown = (e) => {
+ if (e.key === 'Enter' && !e.shiftKey && !loading) {
+ e.preventDefault()
+ handleCheck()
+ }
+ }
+
+ const charCount = sentence.length
+ const isOverLimit = charCount > TEXT_LIMITS.MAX_SENTENCE_LENGTH
+ const canSubmit =
+ sentence.trim().length >= TEXT_LIMITS.MIN_SENTENCE_LENGTH &&
+ !isOverLimit &&
+ !loading
+
+ const levelOptions = [
+ {
+ value: GRAMMAR_LEVELS.BEGINNER,
+ label: t('grammar.beginner'),
+ desc: t('grammar.beginnerDesc'),
+ },
+ {
+ value: GRAMMAR_LEVELS.INTERMEDIATE,
+ label: t('grammar.intermediate'),
+ desc: t('grammar.intermediateDesc'),
+ },
+ {
+ value: GRAMMAR_LEVELS.ADVANCED,
+ label: t('grammar.advanced'),
+ desc: t('grammar.advancedDesc'),
+ },
+ ]
+
+ return (
+
+ {/* Level Selection */}
+
+
+
+
+ {t('grammar.selectLevel')}
+
+
+
+ {levelOptions.map((option) => (
+
+
+
+ {option.label}
+
+
+ {option.desc}
+
+
+
+ ))}
+
+
+
+ {/* Text Input */}
+
+
+
+ {/* Character Count */}
+
+
+ {charCount} / {TEXT_LIMITS.MAX_SENTENCE_LENGTH}
+
+
+
+
+ {/* Submit Button */}
+
+ ) : (
+
+ )
+ }
+ sx={{
+ py: 1.75,
+ borderRadius: '14px',
+ fontSize: '1rem',
+ fontWeight: 700,
+ textTransform: 'none',
+ background: `linear-gradient(135deg, ${GRAMMAR_LEVEL_COLORS[level]} 0%, ${GRAMMAR_LEVEL_COLORS[level]}dd 100%)`,
+ boxShadow: `0 8px 24px -4px ${GRAMMAR_LEVEL_COLORS[level]}40`,
+ '&:hover': {
+ background: `linear-gradient(135deg, ${GRAMMAR_LEVEL_COLORS[level]}ee 0%, ${GRAMMAR_LEVEL_COLORS[level]} 100%)`,
+ boxShadow: `0 12px 28px -4px ${GRAMMAR_LEVEL_COLORS[level]}50`,
+ },
+ '&:disabled': {
+ background: '#e5e7eb',
+ color: '#9ca3af',
+ boxShadow: 'none',
+ },
+ }}
+ >
+ {loading ? t('grammar.checking') : t('grammar.checkButton')}
+
+
+ )
+}
diff --git a/src/domains/grammar/components/GrammarResult.jsx b/src/domains/grammar/components/GrammarResult.jsx
new file mode 100644
index 0000000..4761230
--- /dev/null
+++ b/src/domains/grammar/components/GrammarResult.jsx
@@ -0,0 +1,407 @@
+import { Box, Typography, Paper, Chip, Collapse, IconButton, Divider } from '@mui/material'
+import {
+ CheckCircle as CheckCircleIcon,
+ Error as ErrorIcon,
+ Lightbulb as LightbulbIcon,
+ ExpandMore as ExpandMoreIcon,
+ ArrowForward as ArrowForwardIcon,
+} from '@mui/icons-material'
+import { useState } from 'react'
+import { useSettings } from '../../../contexts/SettingsContext'
+import {
+ GRAMMAR_ERROR_COLORS,
+ GRAMMAR_ERROR_BG_COLORS,
+ getScoreGrade,
+ getScoreColor,
+} from '../constants/grammarConstants'
+
+export default function GrammarResult({ result }) {
+ const { t, isKorean } = useSettings()
+ const [expandedError, setExpandedError] = useState(null)
+
+ if (!result) return null
+
+ const {
+ originalSentence,
+ correctedSentence,
+ score,
+ isCorrect,
+ errors = [],
+ feedback,
+ } = result
+
+ const scoreGrade = getScoreGrade(score)
+ const scoreColor = getScoreColor(score)
+
+ const toggleError = (index) => {
+ setExpandedError(expandedError === index ? null : index)
+ }
+
+ // 문장에서 오류 부분을 하이라이트하는 함수
+ const renderHighlightedSentence = (sentence, sentenceErrors, isCorrected = false) => {
+ if (!sentenceErrors || sentenceErrors.length === 0) {
+ return {sentence}
+ }
+
+ // 오류를 시작 인덱스 기준으로 정렬
+ const sortedErrors = [...sentenceErrors].sort((a, b) => a.startIndex - b.startIndex)
+ const parts = []
+ let lastIndex = 0
+
+ sortedErrors.forEach((error, idx) => {
+ // 오류 이전 텍스트
+ if (error.startIndex > lastIndex) {
+ parts.push(
+
+ {sentence.slice(lastIndex, error.startIndex)}
+
+ )
+ }
+
+ // 오류 부분 하이라이트
+ const highlightText = isCorrected ? error.corrected : error.original
+ parts.push(
+
+ {highlightText}
+
+ )
+
+ lastIndex = error.endIndex
+ })
+
+ // 마지막 오류 이후 텍스트
+ if (lastIndex < sentence.length) {
+ parts.push(
+ {sentence.slice(lastIndex)}
+ )
+ }
+
+ return (
+
+ {parts}
+
+ )
+ }
+
+ return (
+
+ {/* Score Header */}
+
+
+
+ {isCorrect ? (
+
+
+
+ ) : (
+
+
+
+ )}
+
+
+ {isCorrect
+ ? t('grammar.perfect')
+ : isKorean
+ ? scoreGrade.labelKo
+ : scoreGrade.label}
+
+
+ {isCorrect
+ ? t('grammar.noProblem')
+ : `${errors.length} ${t('grammar.errorCount')}`}
+
+
+
+
+ {/* Score Circle */}
+
+
+ {score}
+
+
+ {t('grammar.score')}
+
+
+
+
+
+ {/* Original vs Corrected */}
+
+ {/* Original Sentence */}
+
+
+ {t('grammar.original')}
+
+
+ {renderHighlightedSentence(originalSentence, errors, false)}
+
+
+
+ {/* Arrow */}
+ {!isCorrect && (
+
+
+
+
+
+ )}
+
+ {/* Corrected Sentence */}
+ {!isCorrect && (
+
+
+ {t('grammar.corrected')}
+
+
+
+ {correctedSentence}
+
+
+
+ )}
+
+ {/* Errors List */}
+ {errors.length > 0 && (
+ <>
+
+
+ {t('grammar.errors')}
+
+
+ {errors.map((error, index) => (
+
+ toggleError(index)}
+ sx={{
+ p: 2,
+ display: 'flex',
+ alignItems: 'center',
+ justifyContent: 'space-between',
+ cursor: 'pointer',
+ backgroundColor: expandedError === index
+ ? GRAMMAR_ERROR_BG_COLORS[error.type]
+ : 'transparent',
+ '&:hover': {
+ backgroundColor: GRAMMAR_ERROR_BG_COLORS[error.type],
+ },
+ }}
+ >
+
+
+
+
+ {error.original}
+
+
+
+ {error.corrected}
+
+
+
+
+
+
+
+
+
+
+ {error.explanation}
+
+
+
+
+ ))}
+
+ >
+ )}
+
+ {/* Feedback */}
+ {feedback && (
+ <>
+
+
+
+
+
+
+ {t('grammar.feedback')}
+
+
+ {feedback}
+
+
+
+
+ >
+ )}
+
+
+ )
+}
diff --git a/src/domains/grammar/components/SessionSidebar.jsx b/src/domains/grammar/components/SessionSidebar.jsx
new file mode 100644
index 0000000..88d1683
--- /dev/null
+++ b/src/domains/grammar/components/SessionSidebar.jsx
@@ -0,0 +1,278 @@
+import { useState } from 'react'
+import {
+ Box,
+ Typography,
+ List,
+ ListItem,
+ ListItemButton,
+ ListItemText,
+ IconButton,
+ Chip,
+ Divider,
+ Button,
+ Dialog,
+ DialogTitle,
+ DialogContent,
+ DialogActions,
+ CircularProgress,
+} from '@mui/material'
+import {
+ Add as AddIcon,
+ Delete as DeleteIcon,
+ Chat as ChatIcon,
+ History as HistoryIcon,
+} from '@mui/icons-material'
+import { useSettings } from '../../../contexts/SettingsContext'
+import { GRAMMAR_LEVEL_COLORS, GRAMMAR_LEVEL_BG_COLORS } from '../constants/grammarConstants'
+
+export default function SessionSidebar({
+ sessions = [],
+ currentSessionId,
+ onSelectSession,
+ onNewSession,
+ onDeleteSession,
+ loading = false,
+}) {
+ const { t, isKorean } = useSettings()
+ const [deleteDialogOpen, setDeleteDialogOpen] = useState(false)
+ const [sessionToDelete, setSessionToDelete] = useState(null)
+
+ const handleDeleteClick = (session, e) => {
+ e.stopPropagation()
+ setSessionToDelete(session)
+ setDeleteDialogOpen(true)
+ }
+
+ const handleConfirmDelete = () => {
+ if (sessionToDelete) {
+ onDeleteSession?.(sessionToDelete.sessionId)
+ }
+ setDeleteDialogOpen(false)
+ setSessionToDelete(null)
+ }
+
+ const formatDate = (dateString) => {
+ const date = new Date(dateString)
+ const now = new Date()
+ const diffMs = now - date
+ const diffMins = Math.floor(diffMs / 60000)
+ const diffHours = Math.floor(diffMs / 3600000)
+ const diffDays = Math.floor(diffMs / 86400000)
+
+ if (diffMins < 1) return isKorean ? '방금 전' : 'Just now'
+ if (diffMins < 60) return isKorean ? `${diffMins}분 전` : `${diffMins}m ago`
+ if (diffHours < 24) return isKorean ? `${diffHours}시간 전` : `${diffHours}h ago`
+ if (diffDays < 7) return isKorean ? `${diffDays}일 전` : `${diffDays}d ago`
+ return date.toLocaleDateString()
+ }
+
+ const getLevelLabel = (level) => {
+ if (!level) return ''
+ const labels = {
+ BEGINNER: isKorean ? '초급' : 'Beginner',
+ INTERMEDIATE: isKorean ? '중급' : 'Intermediate',
+ ADVANCED: isKorean ? '고급' : 'Advanced',
+ }
+ return labels[level] || level
+ }
+
+ return (
+
+ {/* Header */}
+
+ }
+ onClick={onNewSession}
+ sx={{
+ py: 1.25,
+ borderRadius: '12px',
+ textTransform: 'none',
+ fontWeight: 600,
+ background: 'linear-gradient(135deg, #059669 0%, #10b981 100%)',
+ boxShadow: '0 4px 12px -2px rgba(5, 150, 105, 0.3)',
+ '&:hover': {
+ background: 'linear-gradient(135deg, #047857 0%, #059669 100%)',
+ },
+ }}
+ >
+ {isKorean ? '새 대화' : 'New Chat'}
+
+
+
+ {/* Sessions List */}
+
+
+
+
+
+ {isKorean ? '대화 기록' : 'History'}
+
+
+
+
+ {loading ? (
+
+
+
+ ) : sessions.length === 0 ? (
+
+
+
+ {isKorean ? '대화 기록이 없습니다' : 'No conversations yet'}
+
+
+ ) : (
+
+ {sessions.map((session) => (
+ handleDeleteClick(session, e)}
+ sx={{
+ opacity: 0,
+ transition: 'opacity 0.2s',
+ '.MuiListItem-root:hover &': {
+ opacity: 1,
+ },
+ }}
+ >
+
+
+ }
+ >
+ onSelectSession?.(session.sessionId)}
+ sx={{
+ borderRadius: '10px',
+ py: 1.5,
+ '&.Mui-selected': {
+ backgroundColor: '#ecfdf5',
+ '&:hover': {
+ backgroundColor: '#d1fae5',
+ },
+ },
+ '&:hover': {
+ backgroundColor: '#f3f4f6',
+ },
+ }}
+ >
+
+
+ {session.topic || (isKorean ? '대화' : 'Chat')}
+
+ {session.level && (
+
+ )}
+
+ }
+ secondary={
+
+
+ {session.lastMessage || `${session.messageCount} ${isKorean ? '메시지' : 'messages'}`}
+
+
+ {formatDate(session.updatedAt)}
+
+
+ }
+ />
+
+
+ ))}
+
+ )}
+
+
+ {/* Delete Confirmation Dialog */}
+
+
+ )
+}
diff --git a/src/domains/grammar/constants/grammarConstants.js b/src/domains/grammar/constants/grammarConstants.js
new file mode 100644
index 0000000..9a16f68
--- /dev/null
+++ b/src/domains/grammar/constants/grammarConstants.js
@@ -0,0 +1,190 @@
+/**
+ * Grammar domain constants
+ * Based on Backend GrammarErrorType enum
+ */
+
+// 문법 오류 타입
+export const GRAMMAR_ERROR_TYPES = {
+ VERB_TENSE: 'VERB_TENSE',
+ SUBJECT_VERB_AGREEMENT: 'SUBJECT_VERB_AGREEMENT',
+ ARTICLE: 'ARTICLE',
+ PREPOSITION: 'PREPOSITION',
+ WORD_ORDER: 'WORD_ORDER',
+ PLURAL_SINGULAR: 'PLURAL_SINGULAR',
+ PRONOUN: 'PRONOUN',
+ SPELLING: 'SPELLING',
+ PUNCTUATION: 'PUNCTUATION',
+ WORD_CHOICE: 'WORD_CHOICE',
+ SENTENCE_STRUCTURE: 'SENTENCE_STRUCTURE',
+ OTHER: 'OTHER',
+}
+
+// 문법 오류 타입 라벨 (한국어)
+export const GRAMMAR_ERROR_LABELS_KO = {
+ VERB_TENSE: '동사 시제',
+ SUBJECT_VERB_AGREEMENT: '주어-동사 일치',
+ ARTICLE: '관사',
+ PREPOSITION: '전치사',
+ WORD_ORDER: '어순',
+ PLURAL_SINGULAR: '단수/복수',
+ PRONOUN: '대명사',
+ SPELLING: '철자',
+ PUNCTUATION: '구두점',
+ WORD_CHOICE: '단어 선택',
+ SENTENCE_STRUCTURE: '문장 구조',
+ OTHER: '기타',
+}
+
+// 문법 오류 타입 라벨 (영어)
+export const GRAMMAR_ERROR_LABELS_EN = {
+ VERB_TENSE: 'Verb Tense',
+ SUBJECT_VERB_AGREEMENT: 'Subject-Verb Agreement',
+ ARTICLE: 'Article',
+ PREPOSITION: 'Preposition',
+ WORD_ORDER: 'Word Order',
+ PLURAL_SINGULAR: 'Singular/Plural',
+ PRONOUN: 'Pronoun',
+ SPELLING: 'Spelling',
+ PUNCTUATION: 'Punctuation',
+ WORD_CHOICE: 'Word Choice',
+ SENTENCE_STRUCTURE: 'Sentence Structure',
+ OTHER: 'Other',
+}
+
+// 문법 오류 타입별 색상
+export const GRAMMAR_ERROR_COLORS = {
+ VERB_TENSE: '#ef4444',
+ SUBJECT_VERB_AGREEMENT: '#f97316',
+ ARTICLE: '#eab308',
+ PREPOSITION: '#22c55e',
+ WORD_ORDER: '#06b6d4',
+ PLURAL_SINGULAR: '#3b82f6',
+ PRONOUN: '#8b5cf6',
+ SPELLING: '#ec4899',
+ PUNCTUATION: '#6b7280',
+ WORD_CHOICE: '#14b8a6',
+ SENTENCE_STRUCTURE: '#f59e0b',
+ OTHER: '#9ca3af',
+}
+
+// 문법 오류 타입별 배경 색상 (밝은 버전)
+export const GRAMMAR_ERROR_BG_COLORS = {
+ VERB_TENSE: '#fef2f2',
+ SUBJECT_VERB_AGREEMENT: '#fff7ed',
+ ARTICLE: '#fefce8',
+ PREPOSITION: '#f0fdf4',
+ WORD_ORDER: '#ecfeff',
+ PLURAL_SINGULAR: '#eff6ff',
+ PRONOUN: '#f5f3ff',
+ SPELLING: '#fdf2f8',
+ PUNCTUATION: '#f9fafb',
+ WORD_CHOICE: '#f0fdfa',
+ SENTENCE_STRUCTURE: '#fffbeb',
+ OTHER: '#f3f4f6',
+}
+
+// 학습 레벨
+export const GRAMMAR_LEVELS = {
+ BEGINNER: 'BEGINNER',
+ INTERMEDIATE: 'INTERMEDIATE',
+ ADVANCED: 'ADVANCED',
+}
+
+// 학습 레벨 라벨 (한국어)
+export const GRAMMAR_LEVEL_LABELS_KO = {
+ BEGINNER: '초급',
+ INTERMEDIATE: '중급',
+ ADVANCED: '고급',
+}
+
+// 학습 레벨 라벨 (영어)
+export const GRAMMAR_LEVEL_LABELS_EN = {
+ BEGINNER: 'Beginner',
+ INTERMEDIATE: 'Intermediate',
+ ADVANCED: 'Advanced',
+}
+
+// 학습 레벨 설명 (한국어)
+export const GRAMMAR_LEVEL_DESC_KO = {
+ BEGINNER: '한국어 번역 포함, 쉬운 설명',
+ INTERMEDIATE: '영어 설명, 일반적인 패턴 학습',
+ ADVANCED: '상세한 문법 규칙, 뉘앙스 학습',
+}
+
+// 학습 레벨 설명 (영어)
+export const GRAMMAR_LEVEL_DESC_EN = {
+ BEGINNER: 'Korean translations, simple explanations',
+ INTERMEDIATE: 'English explanations, common patterns',
+ ADVANCED: 'Detailed rules, nuanced usage',
+}
+
+// 학습 레벨 색상
+export const GRAMMAR_LEVEL_COLORS = {
+ BEGINNER: '#059669',
+ INTERMEDIATE: '#f97316',
+ ADVANCED: '#ef4444',
+}
+
+// 학습 레벨 배경 색상
+export const GRAMMAR_LEVEL_BG_COLORS = {
+ BEGINNER: '#ecfdf5',
+ INTERMEDIATE: '#fff7ed',
+ ADVANCED: '#fef2f2',
+}
+
+// 점수 등급
+export const SCORE_GRADES = {
+ EXCELLENT: { min: 90, max: 100, label: 'Excellent', labelKo: '훌륭해요!' },
+ GOOD: { min: 70, max: 89, label: 'Good', labelKo: '잘했어요!' },
+ FAIR: { min: 50, max: 69, label: 'Fair', labelKo: '괜찮아요' },
+ POOR: { min: 0, max: 49, label: 'Needs Practice', labelKo: '더 연습해요' },
+}
+
+// 점수에 따른 등급 반환
+export const getScoreGrade = (score) => {
+ if (score >= 90) return SCORE_GRADES.EXCELLENT
+ if (score >= 70) return SCORE_GRADES.GOOD
+ if (score >= 50) return SCORE_GRADES.FAIR
+ return SCORE_GRADES.POOR
+}
+
+// 점수에 따른 색상 반환
+export const getScoreColor = (score) => {
+ if (score >= 90) return '#059669'
+ if (score >= 70) return '#f97316'
+ if (score >= 50) return '#eab308'
+ return '#ef4444'
+}
+
+// 메시지 역할
+export const MESSAGE_ROLES = {
+ USER: 'USER',
+ ASSISTANT: 'ASSISTANT',
+}
+
+// 텍스트 제한
+export const TEXT_LIMITS = {
+ MIN_SENTENCE_LENGTH: 3,
+ MAX_SENTENCE_LENGTH: 500,
+ MAX_MESSAGE_LENGTH: 1000,
+}
+
+export default {
+ GRAMMAR_ERROR_TYPES,
+ GRAMMAR_ERROR_LABELS_KO,
+ GRAMMAR_ERROR_LABELS_EN,
+ GRAMMAR_ERROR_COLORS,
+ GRAMMAR_ERROR_BG_COLORS,
+ GRAMMAR_LEVELS,
+ GRAMMAR_LEVEL_LABELS_KO,
+ GRAMMAR_LEVEL_LABELS_EN,
+ GRAMMAR_LEVEL_DESC_KO,
+ GRAMMAR_LEVEL_DESC_EN,
+ GRAMMAR_LEVEL_COLORS,
+ GRAMMAR_LEVEL_BG_COLORS,
+ SCORE_GRADES,
+ getScoreGrade,
+ getScoreColor,
+ MESSAGE_ROLES,
+ TEXT_LIMITS,
+}
diff --git a/src/domains/grammar/index.js b/src/domains/grammar/index.js
new file mode 100644
index 0000000..c9cf947
--- /dev/null
+++ b/src/domains/grammar/index.js
@@ -0,0 +1,15 @@
+// Pages
+export { default as WritingPage } from './pages/WritingPage'
+
+// Components
+export { default as GrammarInput } from './components/GrammarInput'
+export { default as GrammarResult } from './components/GrammarResult'
+export { default as ChatMessage } from './components/ChatMessage'
+export { default as ChatInput } from './components/ChatInput'
+export { default as SessionSidebar } from './components/SessionSidebar'
+
+// Services
+export * from './services/grammarService'
+
+// Constants
+export * from './constants/grammarConstants'
diff --git a/src/domains/grammar/pages/WritingPage.jsx b/src/domains/grammar/pages/WritingPage.jsx
new file mode 100644
index 0000000..7ea3142
--- /dev/null
+++ b/src/domains/grammar/pages/WritingPage.jsx
@@ -0,0 +1,364 @@
+import { useState, useEffect, useRef } from 'react'
+import {
+ Box,
+ Typography,
+ IconButton,
+ Drawer,
+ useMediaQuery,
+ useTheme,
+ Alert,
+} from '@mui/material'
+import {
+ Menu as MenuIcon,
+ Edit as EditIcon,
+ SmartToy as AiIcon,
+} from '@mui/icons-material'
+import { useSettings } from '../../../contexts/SettingsContext'
+import ChatMessage from '../components/ChatMessage'
+import ChatInput from '../components/ChatInput'
+import SessionSidebar from '../components/SessionSidebar'
+import { conversationService, sessionService } from '../services/grammarService'
+import { GRAMMAR_LEVELS } from '../constants/grammarConstants'
+
+export default function WritingPage() {
+ const { t, isKorean } = useSettings()
+ const theme = useTheme()
+ const isMobile = useMediaQuery(theme.breakpoints.down('md'))
+
+ const [sidebarOpen, setSidebarOpen] = useState(!isMobile)
+ const [sessions, setSessions] = useState([])
+ const [currentSessionId, setCurrentSessionId] = useState(null)
+ const [messages, setMessages] = useState([])
+ const [level, setLevel] = useState(GRAMMAR_LEVELS.BEGINNER)
+ const [loading, setLoading] = useState(false)
+ const [sessionsLoading, setSessionsLoading] = useState(true)
+ const [error, setError] = useState(null)
+
+ const messagesEndRef = useRef(null)
+
+ // Load sessions on mount
+ useEffect(() => {
+ loadSessions()
+ }, [])
+
+ // Scroll to bottom when messages change
+ useEffect(() => {
+ messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
+ }, [messages])
+
+ const loadSessions = async () => {
+ try {
+ setSessionsLoading(true)
+ const response = await sessionService.getList({ limit: 20 })
+ setSessions(response.sessions || [])
+ } catch (err) {
+ console.error('Failed to load sessions:', err)
+ } finally {
+ setSessionsLoading(false)
+ }
+ }
+
+ const loadSessionMessages = async (sessionId) => {
+ try {
+ setLoading(true)
+ const response = await sessionService.getDetail(sessionId)
+ if (response.session) {
+ setLevel(response.session.level || GRAMMAR_LEVELS.BEGINNER)
+ }
+ // Convert messages to our format
+ const formattedMessages = (response.messages || []).map((msg) => ({
+ id: msg.messageId,
+ content: msg.content,
+ correctedContent: msg.correctedContent,
+ grammarScore: msg.grammarScore,
+ errors: msg.errorsJson ? JSON.parse(msg.errorsJson) : [],
+ aiResponse: msg.role === 'ASSISTANT' ? msg.content : null,
+ isUser: msg.role === 'USER',
+ createdAt: msg.createdAt,
+ }))
+ setMessages(formattedMessages)
+ } catch (err) {
+ console.error('Failed to load session messages:', err)
+ setError(isKorean ? '메시지를 불러오는데 실패했습니다' : 'Failed to load messages')
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ const handleSelectSession = (sessionId) => {
+ setCurrentSessionId(sessionId)
+ loadSessionMessages(sessionId)
+ if (isMobile) {
+ setSidebarOpen(false)
+ }
+ }
+
+ const handleNewSession = () => {
+ setCurrentSessionId(null)
+ setMessages([])
+ setError(null)
+ if (isMobile) {
+ setSidebarOpen(false)
+ }
+ }
+
+ const handleDeleteSession = async (sessionId) => {
+ try {
+ await sessionService.delete(sessionId)
+ setSessions((prev) => prev.filter((s) => s.sessionId !== sessionId))
+ if (currentSessionId === sessionId) {
+ handleNewSession()
+ }
+ } catch (err) {
+ console.error('Failed to delete session:', err)
+ }
+ }
+
+ const handleSendMessage = async (message) => {
+ try {
+ setLoading(true)
+ setError(null)
+
+ // Add user message immediately
+ const userMessage = {
+ id: `temp-${Date.now()}`,
+ content: message,
+ isUser: true,
+ createdAt: new Date().toISOString(),
+ }
+ setMessages((prev) => [...prev, userMessage])
+
+ // Send to API
+ const response = await conversationService.send(message, currentSessionId, level)
+
+ // Update session ID if new
+ if (!currentSessionId && response.sessionId) {
+ setCurrentSessionId(response.sessionId)
+ // Reload sessions to show new one
+ loadSessions()
+ }
+
+ // Update user message with grammar check results
+ setMessages((prev) =>
+ prev.map((msg) =>
+ msg.id === userMessage.id
+ ? {
+ ...msg,
+ correctedContent: response.grammarCheck?.correctedSentence,
+ grammarScore: response.grammarCheck?.score,
+ errors: response.grammarCheck?.errors || [],
+ }
+ : msg
+ )
+ )
+
+ // Add AI response
+ const aiMessage = {
+ id: `ai-${Date.now()}`,
+ content: response.aiResponse,
+ aiResponse: response.aiResponse,
+ conversationTip: response.conversationTip,
+ isUser: false,
+ createdAt: new Date().toISOString(),
+ }
+ setMessages((prev) => [...prev, aiMessage])
+ } catch (err) {
+ console.error('Failed to send message:', err)
+ setError(isKorean ? '메시지 전송에 실패했습니다' : 'Failed to send message')
+ // Remove temporary user message on error
+ setMessages((prev) => prev.filter((msg) => !msg.id.startsWith('temp-')))
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ const sidebarContent = (
+
+ )
+
+ return (
+
+ {/* Sidebar - Desktop */}
+ {!isMobile && sidebarOpen && sidebarContent}
+
+ {/* Sidebar - Mobile Drawer */}
+ {isMobile && (
+ setSidebarOpen(false)}
+ PaperProps={{
+ sx: { width: 280 },
+ }}
+ >
+ {sidebarContent}
+
+ )}
+
+ {/* Main Chat Area */}
+
+ {/* Header */}
+
+ setSidebarOpen(!sidebarOpen)} sx={{ mr: 0.5 }}>
+
+
+
+
+
+
+
+
+
+ {t('grammar.title')}
+
+
+ {t('grammar.subtitle')}
+
+
+
+
+ {/* Messages Area */}
+
+ {error && (
+
+ {error}
+
+ )}
+
+ {messages.length === 0 ? (
+
+
+
+
+
+ {isKorean ? 'AI 문법 교정 도우미' : 'AI Grammar Assistant'}
+
+
+ {isKorean
+ ? '영어 문장을 입력하면 문법을 교정하고, 자연스러운 대화로 영어 실력을 향상시켜 드립니다.'
+ : 'Type an English sentence to get grammar corrections and improve your English through natural conversation.'}
+
+
+
+
+ {isKorean ? '예시 문장:' : 'Example sentences:'}
+
+
+ • "I go to school yesterday."
+
+
+ • "She have a beautiful car."
+
+
+ • "The informations is useful."
+
+
+
+ ) : (
+ <>
+ {messages.map((msg) => (
+
+ ))}
+
+ >
+ )}
+
+
+ {/* Input Area */}
+
+
+
+ )
+}
diff --git a/src/domains/grammar/services/grammarService.js b/src/domains/grammar/services/grammarService.js
new file mode 100644
index 0000000..3de64c1
--- /dev/null
+++ b/src/domains/grammar/services/grammarService.js
@@ -0,0 +1,228 @@
+import grammarApi from '../../../api/grammarApi'
+
+// Mock 데이터 사용 여부 (true: 목 데이터 사용, false: 실제 API 호출)
+const USE_MOCK = true
+
+// ============================================
+// Mock 데이터
+// ============================================
+
+const mockGrammarCheckResponse = {
+ originalSentence: 'She go to the school yesterday.',
+ correctedSentence: 'She went to school yesterday.',
+ score: 60,
+ isCorrect: false,
+ errors: [
+ {
+ type: 'VERB_TENSE',
+ original: 'go',
+ corrected: 'went',
+ explanation: '과거 시제에서는 "go"가 "went"로 변합니다.',
+ startIndex: 4,
+ endIndex: 6,
+ },
+ {
+ type: 'ARTICLE',
+ original: 'the school',
+ corrected: 'school',
+ explanation: '일반적인 장소를 나타낼 때는 관사를 생략합니다.',
+ startIndex: 11,
+ endIndex: 21,
+ },
+ ],
+ feedback:
+ '동사 시제와 관사 사용에 주의가 필요합니다. 과거의 일을 말할 때는 과거 시제를 사용하세요.',
+}
+
+const mockConversationHistory = [
+ {
+ messageId: 'msg-1',
+ role: 'USER',
+ content: 'Hello, how are you?',
+ correctedContent: 'Hello, how are you?',
+ grammarScore: 100,
+ createdAt: new Date(Date.now() - 300000).toISOString(),
+ },
+ {
+ messageId: 'msg-2',
+ role: 'ASSISTANT',
+ content: "I'm doing great, thank you for asking! How about you?",
+ createdAt: new Date(Date.now() - 240000).toISOString(),
+ },
+ {
+ messageId: 'msg-3',
+ role: 'USER',
+ content: 'I am fine. I go to work today.',
+ correctedContent: 'I am fine. I went to work today.',
+ grammarScore: 85,
+ errorsJson: JSON.stringify([
+ {
+ type: 'VERB_TENSE',
+ original: 'go',
+ corrected: 'went',
+ explanation: '과거의 일을 말할 때는 과거 시제를 사용합니다.',
+ },
+ ]),
+ createdAt: new Date(Date.now() - 180000).toISOString(),
+ },
+ {
+ messageId: 'msg-4',
+ role: 'ASSISTANT',
+ content: "That's great! What kind of work do you do?",
+ createdAt: new Date(Date.now() - 120000).toISOString(),
+ },
+]
+
+const mockSessions = [
+ {
+ sessionId: 'session-1',
+ userId: 'user1',
+ level: 'BEGINNER',
+ topic: 'Daily Conversation',
+ messageCount: 8,
+ lastMessage: 'Thank you for practicing with me!',
+ createdAt: new Date(Date.now() - 86400000).toISOString(),
+ updatedAt: new Date(Date.now() - 3600000).toISOString(),
+ },
+ {
+ sessionId: 'session-2',
+ userId: 'user1',
+ level: 'INTERMEDIATE',
+ topic: 'Business English',
+ messageCount: 12,
+ lastMessage: 'I will schedule a meeting for next week.',
+ createdAt: new Date(Date.now() - 172800000).toISOString(),
+ updatedAt: new Date(Date.now() - 7200000).toISOString(),
+ },
+ {
+ sessionId: 'session-3',
+ userId: 'user1',
+ level: 'BEGINNER',
+ topic: 'Travel Conversation',
+ messageCount: 6,
+ lastMessage: 'Where is the nearest subway station?',
+ createdAt: new Date(Date.now() - 259200000).toISOString(),
+ updatedAt: new Date(Date.now() - 86400000).toISOString(),
+ },
+]
+
+// ============================================
+// API with Mock fallback
+// ============================================
+
+const withMock = (apiCall, mockData) => {
+ if (USE_MOCK) {
+ return new Promise((resolve) => {
+ setTimeout(() => resolve(mockData), 800)
+ })
+ }
+ return apiCall().catch(() => mockData)
+}
+
+/**
+ * 문법 검사 API - Backend: POST /grammar/check
+ */
+export const grammarCheckService = {
+ // POST /grammar/check - 문법 검사
+ check: (sentence, level = 'BEGINNER') =>
+ withMock(
+ () => grammarApi.post('/grammar/check', { sentence, level }),
+ {
+ ...mockGrammarCheckResponse,
+ originalSentence: sentence,
+ correctedSentence:
+ sentence.length > 10
+ ? sentence.replace(/go /g, 'went ').replace(/the school/g, 'school')
+ : sentence,
+ isCorrect: sentence.length <= 10,
+ score: sentence.length <= 10 ? 100 : Math.floor(Math.random() * 40) + 60,
+ errors:
+ sentence.length <= 10
+ ? []
+ : mockGrammarCheckResponse.errors.slice(0, Math.floor(Math.random() * 2) + 1),
+ }
+ ),
+}
+
+/**
+ * 대화형 문법 교정 API - Backend: POST /grammar/conversation
+ */
+export const conversationService = {
+ // POST /grammar/conversation - AI 대화
+ send: (message, sessionId = null, level = 'BEGINNER') =>
+ withMock(
+ () => grammarApi.post('/grammar/conversation', { message, sessionId, level }),
+ {
+ sessionId: sessionId || `session-${Date.now()}`,
+ grammarCheck: {
+ originalSentence: message,
+ correctedSentence: message.replace(/go /g, 'went '),
+ score: Math.floor(Math.random() * 30) + 70,
+ isCorrect: !message.includes('go '),
+ errors: message.includes('go ')
+ ? [
+ {
+ type: 'VERB_TENSE',
+ original: 'go',
+ corrected: 'went',
+ explanation: '과거 시제를 사용해야 합니다.',
+ startIndex: message.indexOf('go '),
+ endIndex: message.indexOf('go ') + 2,
+ },
+ ]
+ : [],
+ feedback: message.includes('go ')
+ ? '동사 시제에 주의하세요!'
+ : '훌륭한 문장입니다!',
+ },
+ aiResponse:
+ "That's interesting! Could you tell me more about it?",
+ conversationTip:
+ 'Try using more descriptive adjectives in your next sentence.',
+ }
+ ),
+}
+
+/**
+ * 세션 관리 API - Backend: GET/DELETE /grammar/sessions
+ */
+export const sessionService = {
+ // GET /grammar/sessions - 세션 목록 조회
+ getList: ({ limit = 10, cursor } = {}) =>
+ withMock(
+ () => grammarApi.get('/grammar/sessions', { params: { limit, cursor } }),
+ {
+ sessions: mockSessions.slice(0, limit),
+ nextCursor: null,
+ hasMore: false,
+ }
+ ),
+
+ // GET /grammar/sessions/{sessionId} - 세션 상세 조회
+ getDetail: (sessionId, { messageLimit = 50 } = {}) =>
+ withMock(
+ () =>
+ grammarApi.get(`/grammar/sessions/${sessionId}`, {
+ params: { messageLimit },
+ }),
+ {
+ session:
+ mockSessions.find((s) => s.sessionId === sessionId) || mockSessions[0],
+ messages: mockConversationHistory.slice(0, messageLimit),
+ }
+ ),
+
+ // DELETE /grammar/sessions/{sessionId} - 세션 삭제
+ delete: (sessionId) =>
+ withMock(() => grammarApi.delete(`/grammar/sessions/${sessionId}`), {
+ code: 'SUCCESS',
+ message: 'Session deleted successfully',
+ }),
+}
+
+// 단일 export로 모든 서비스 묶기
+export default {
+ grammarCheckService,
+ conversationService,
+ sessionService,
+}
diff --git a/src/i18n/translations.js b/src/i18n/translations.js
index e4b4f87..e96c160 100644
--- a/src/i18n/translations.js
+++ b/src/i18n/translations.js
@@ -297,6 +297,58 @@ export const translations = {
message: '요청하신 페이지가 존재하지 않거나 이동되었습니다.',
backHome: '홈으로 돌아가기',
},
+
+ // Grammar Check
+ grammar: {
+ title: '문법 교정',
+ subtitle: 'AI가 문법을 검사하고 피드백을 제공합니다',
+ inputPlaceholder: '검사할 영어 문장을 입력하세요...',
+ checkButton: '문법 검사',
+ checking: '검사 중...',
+ selectLevel: '학습 레벨',
+ beginner: '초급',
+ beginnerDesc: '한국어 번역 포함, 쉬운 설명',
+ intermediate: '중급',
+ intermediateDesc: '영어 설명, 일반적인 패턴 학습',
+ advanced: '고급',
+ advancedDesc: '상세한 문법 규칙, 뉘앙스 학습',
+ result: '검사 결과',
+ original: '원문',
+ corrected: '교정문',
+ score: '점수',
+ perfect: '완벽해요!',
+ noProblem: '문법 오류가 없습니다',
+ errors: '발견된 오류',
+ errorCount: '개 오류 발견',
+ feedback: '피드백',
+ suggestions: '개선 제안',
+ tryAgain: '다시 검사',
+ newSentence: '새 문장',
+ history: '검사 기록',
+ noHistory: '검사 기록이 없습니다',
+ minLength: '최소 3자 이상 입력하세요',
+ maxLength: '최대 500자까지 입력 가능합니다',
+ errorTypes: {
+ VERB_TENSE: '동사 시제',
+ SUBJECT_VERB_AGREEMENT: '주어-동사 일치',
+ ARTICLE: '관사',
+ PREPOSITION: '전치사',
+ WORD_ORDER: '어순',
+ PLURAL_SINGULAR: '단수/복수',
+ PRONOUN: '대명사',
+ SPELLING: '철자',
+ PUNCTUATION: '구두점',
+ WORD_CHOICE: '단어 선택',
+ SENTENCE_STRUCTURE: '문장 구조',
+ OTHER: '기타',
+ },
+ scoreGrade: {
+ excellent: '훌륭해요!',
+ good: '잘했어요!',
+ fair: '괜찮아요',
+ poor: '더 연습해요',
+ },
+ },
},
en: {
@@ -592,6 +644,58 @@ export const translations = {
message: "The page you're looking for doesn't exist or has been moved.",
backHome: 'Back to Home',
},
+
+ // Grammar Check
+ grammar: {
+ title: 'Grammar Check',
+ subtitle: 'AI checks your grammar and provides feedback',
+ inputPlaceholder: 'Enter an English sentence to check...',
+ checkButton: 'Check Grammar',
+ checking: 'Checking...',
+ selectLevel: 'Learning Level',
+ beginner: 'Beginner',
+ beginnerDesc: 'Korean translations, simple explanations',
+ intermediate: 'Intermediate',
+ intermediateDesc: 'English explanations, common patterns',
+ advanced: 'Advanced',
+ advancedDesc: 'Detailed rules, nuanced usage',
+ result: 'Results',
+ original: 'Original',
+ corrected: 'Corrected',
+ score: 'Score',
+ perfect: 'Perfect!',
+ noProblem: 'No grammar errors found',
+ errors: 'Errors Found',
+ errorCount: 'errors found',
+ feedback: 'Feedback',
+ suggestions: 'Suggestions',
+ tryAgain: 'Check Again',
+ newSentence: 'New Sentence',
+ history: 'History',
+ noHistory: 'No check history',
+ minLength: 'Enter at least 3 characters',
+ maxLength: 'Maximum 500 characters allowed',
+ errorTypes: {
+ VERB_TENSE: 'Verb Tense',
+ SUBJECT_VERB_AGREEMENT: 'Subject-Verb Agreement',
+ ARTICLE: 'Article',
+ PREPOSITION: 'Preposition',
+ WORD_ORDER: 'Word Order',
+ PLURAL_SINGULAR: 'Singular/Plural',
+ PRONOUN: 'Pronoun',
+ SPELLING: 'Spelling',
+ PUNCTUATION: 'Punctuation',
+ WORD_CHOICE: 'Word Choice',
+ SENTENCE_STRUCTURE: 'Sentence Structure',
+ OTHER: 'Other',
+ },
+ scoreGrade: {
+ excellent: 'Excellent!',
+ good: 'Good job!',
+ fair: 'Not bad',
+ poor: 'Keep practicing',
+ },
+ },
},
}