diff --git a/src/app/(page)/disputes/[id]/DisputePage.module.scss b/src/app/(page)/disputes/[id]/DisputePage.module.scss index 731856a..57157f7 100644 --- a/src/app/(page)/disputes/[id]/DisputePage.module.scss +++ b/src/app/(page)/disputes/[id]/DisputePage.module.scss @@ -186,7 +186,6 @@ .statementCardEditable { cursor: pointer; - &:hover, &:focus { border-color: v.$color-primary-500; outline: none; diff --git a/src/app/(page)/disputes/[id]/page.tsx b/src/app/(page)/disputes/[id]/page.tsx index bf9345d..df9f0e4 100644 --- a/src/app/(page)/disputes/[id]/page.tsx +++ b/src/app/(page)/disputes/[id]/page.tsx @@ -87,7 +87,15 @@ export default function DisputePage({ params }: { params: Promise<{ id: string } '진술 내용을 정리하고 있어요', '거의 다 됐어요, 잠시만요', ]; + const JUDGING_MESSAGES = [ + '작성한 진술서를 바탕으로\n분석중이에요', + 'AI가 양측 진술을\n검토하고 있어요', + '핵심 쟁점을\n파악하고 있어요', + '공정한 판결을\n준비하고 있어요', + '거의 다 됐어요,\n잠시만요', + ]; const [metaMsgIdx, setMetaMsgIdx] = React.useState(0); + const [judgingMsgIdx, setJudgingMsgIdx] = React.useState(0); React.useEffect(() => { if (!isExtractingMeta) return; const timer = setInterval(() => { @@ -96,13 +104,23 @@ export default function DisputePage({ params }: { params: Promise<{ id: string } return () => clearInterval(timer); }, [isExtractingMeta]); + React.useEffect(() => { + if (!isJudging) return; + const timer = setInterval(() => { + setJudgingMsgIdx((i) => (i + 1) % JUDGING_MESSAGES.length); + }, 2500); + return () => clearInterval(timer); + }, [isJudging]); + // 판결 완료/종료 상태일 때만 fetch — 불필요한 API 호출 방지 + const isJudged = !!dispute && dispute.status === 'judged'; + const { data: judgment, isLoading: judgmentLoading, isError: judgmentError, error: judgmentErrorData, - } = useJudgment(id, isCompleted); + } = useJudgment(id, isJudged); const showToast = useToastStore((s) => s.show); @@ -187,7 +205,7 @@ export default function DisputePage({ params }: { params: Promise<{ id: string }
-

{'작성한 진술서를 바탕으로\n분석중이에요'}

+

{JUDGING_MESSAGES[judgingMsgIdx]}

); @@ -347,10 +365,14 @@ export default function DisputePage({ params }: { params: Promise<{ id: string }

판결 결과를 불러올 수 없습니다.

+ ) : !judgment ? ( +
+

판결 결과를 불러올 수 없습니다.

+
) : judgmentSubTab === 'verdict' ? ( - + ) : ( - + )} )} diff --git a/src/app/(page)/rooms/[roomId]/statement/StatementPage.module.scss b/src/app/(page)/rooms/[roomId]/statement/StatementPage.module.scss new file mode 100644 index 0000000..c8d84a5 --- /dev/null +++ b/src/app/(page)/rooms/[roomId]/statement/StatementPage.module.scss @@ -0,0 +1,155 @@ +@use '@/styles/abstracts/variables' as v; +@use '@/styles/abstracts/functions' as fn; +@use '@/styles/abstracts/mixins' as m; + +.page { + display: flex; + flex-direction: column; + background-color: var(--bg-page); +} + +.content { + padding: fn.r(16) fn.r(20) fn.r(100); + display: flex; + flex-direction: column; + gap: fn.r(16); +} + +.section { + display: flex; + flex-direction: column; + gap: fn.r(16); +} + +.label { + @include m.text-body-s; + + color: v.$color-black; + margin: 0; +} + +.categories { + display: flex; + flex-direction: row; +} + +.categoryItem { + display: flex; + flex-direction: column; + align-items: center; + gap: fn.r(4); + width: fn.r(44); +} + +.categoryIcon { + display: flex; + align-items: center; + justify-content: center; + width: fn.r(44); + height: fn.r(44); + border-radius: fn.r(8); + background-color: var(--bg-surface); + border: 1px solid var(--border-brand); +} + +.categoryLabel { + @include m.text-caption; + font-weight: v.$font-weight-bold; + color: var(--text-primary); + white-space: nowrap; +} + +.statementGroup { + display: flex; + flex-direction: column; + gap: fn.r(8); +} + +.textarea { + min-height: fn.r(350); +} + +.footer { + padding: 0 fn.r(20) fn.r(40); + background-color: var(--bg-page); +} + +.footerRow { + display: flex; + gap: fn.r(8); +} + +.modalOverlay { + position: fixed; + inset: 0; + background-color: rgba(0, 0, 0, 0.4); + display: flex; + align-items: center; + justify-content: center; + z-index: 100; + padding: fn.r(20); +} + +.modal { + background-color: var(--bg-page); + border-radius: fn.r(16); + padding: fn.r(24) fn.r(20); + width: 100%; + max-width: fn.r(320); + display: flex; + flex-direction: column; + gap: fn.r(20); +} + +.modalText { + @include m.text-body-m; + color: var(--text-primary); + margin: 0; + text-align: center; +} + +.modalSubText { + @include m.text-body-s; + + color: var(--text-secondary); + margin: fn.r(-12) 0 0; + text-align: center; +} + +.modalActions { + display: flex; + flex-direction: column; + gap: fn.r(8); +} + +// =========================== +// Saving (로딩 화면) +// =========================== + +.savingScreen { + position: fixed; + inset: 0; + background: linear-gradient(180deg, #{v.$color-primary-200} 0%, #{v.$color-white} 50%); + display: flex; + align-items: center; + justify-content: center; + z-index: 200; +} + +.savingContent { + display: flex; + flex-direction: column; + align-items: center; + gap: fn.r(24); + width: fn.r(362); +} + +.savingText { + font-family: v.$font-family-base; + font-size: v.$font-size-title-m; + font-weight: v.$font-weight-semibold; + color: v.$color-black; + text-align: center; + white-space: pre-line; + line-height: 1.5; +} diff --git a/src/app/(page)/rooms/[roomId]/statement/page.tsx b/src/app/(page)/rooms/[roomId]/statement/page.tsx new file mode 100644 index 0000000..d7049f0 --- /dev/null +++ b/src/app/(page)/rooms/[roomId]/statement/page.tsx @@ -0,0 +1,205 @@ +'use client' + +import React from 'react' +import { useRouter } from 'next/navigation' +import { useHeaderStore } from '@/stores/headerStore' +import { useUserMe } from '@/domains/user/hooks' +import Button from '@/components/ui/Button' +import Spinner from '@/components/ui/Spinner' +import Select from '@/components/ui/Select' +import Textarea from '@/components/ui/Textarea' +import { CATEGORY_ICON_MAP, CATEGORY_LABEL_MAP } from '@/components/ui/CategoryIcon' +import type { CategoryGroup } from '@/types/common' +import styles from './StatementPage.module.scss' + +const VALID_CATEGORIES: CategoryGroup[] = ['romance', 'work', 'friend', 'family'] + +const MBTI_OPTIONS = [ + 'INTJ', 'INTP', 'ENTJ', 'ENTP', + 'INFJ', 'INFP', 'ENFJ', 'ENFP', + 'ISTJ', 'ISFJ', 'ESTJ', 'ESFJ', + 'ISTP', 'ISFP', 'ESTP', 'ESFP', +].map((v) => ({ value: v, label: v })) + +const AI_ERROR_CODES = ['AI_EXTRACTION_FAILED', 'AI_EXTRACTION_TIMEOUT', 'AI_EXTRACTION_PARSE_ERROR'] + +export default function NewStatementPage({ + params, + searchParams, +}: { + params: Promise<{ roomId: string }> + searchParams: Promise<{ category?: string }> +}) { + const { roomId } = React.use(params) + const { category: rawCategory } = React.use(searchParams) + const router = useRouter() + const setHeader = useHeaderStore((s) => s.setHeader) + const { data: userMe } = useUserMe() + + React.useEffect(() => { + setHeader({ variant: 'title', title: '사건작성', onBack: () => router.back() }) + return () => setHeader(null) + }, []) + + const category: CategoryGroup = VALID_CATEGORIES.includes(rawCategory as CategoryGroup) + ? (rawCategory as CategoryGroup) + : 'romance' + + const [mbti, setMbti] = React.useState('') + const [content, setContent] = React.useState('') + const SAVING_MESSAGES = [ + '나쁜 말을 검열 중입니다', + '내용을 저장 중입니다', + 'AI가 분석 중입니다', + '조금만 기다려주세요', + ] + + const [isLoading, setIsLoading] = React.useState(false) + const [savingMsgIdx, setSavingMsgIdx] = React.useState(0) + const [filterMessage, setFilterMessage] = React.useState(null) + const [showPersonalInfoWarning, setShowPersonalInfoWarning] = React.useState(false) + const [savedDisputeId, setSavedDisputeId] = React.useState(null) + + React.useEffect(() => { + if (!userMe) return + setMbti(userMe.mbti ?? '') + }, [userMe]) + + React.useEffect(() => { + if (!isLoading) return + const timer = setInterval(() => { + setSavingMsgIdx((i) => (i + 1) % SAVING_MESSAGES.length) + }, 2000) + return () => clearInterval(timer) + }, [isLoading]) + + const handleCancel = () => { + fetch(`/api/rooms/${roomId}`, { method: 'DELETE', keepalive: true }).catch(() => {}) + router.push('/') + } + + const handleSave = async () => { + if (isLoading) return + setIsLoading(true) + setFilterMessage(null) + + try { + const res = await fetch('/api/disputes', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ roomId, categoryGroup: category, content }), + }) + const json = await res.json() as { + success: boolean + data?: { id: string; hasPersonalInfo?: boolean } + error?: { code?: string; message?: string } + } + + if (!json.success) { + setIsLoading(false) + if (json.error?.code && AI_ERROR_CODES.includes(json.error.code)) { + alert(json.error.message ?? 'AI 분석에 실패했습니다. 다시 시도해주세요.') + return + } + setFilterMessage(json.error?.message ?? '저장 중 오류가 발생했습니다. 다시 시도해주세요.') + return + } + + if (json.data?.hasPersonalInfo) { + setSavedDisputeId(json.data.id) + setIsLoading(false) + setShowPersonalInfoWarning(true) + return + } + + router.push(`/disputes/${json.data!.id}`) + } catch { + setIsLoading(false) + setFilterMessage('네트워크 오류가 발생했습니다. 다시 시도해주세요.') + } + } + + const Icon = CATEGORY_ICON_MAP[category] + + if (isLoading) { + return ( +
+
+ +

{SAVING_MESSAGES[savingMsgIdx]}

+
+
+ ) + } + + return ( +
+
+
+

사건 카테고리

+
+
+
+ +
+ + {CATEGORY_LABEL_MAP[category]} + +
+
+
+ +
+

작성자님의 진술서

+
+