diff --git a/src/features/retrospectives/components/AiGradientIcon.tsx b/src/features/retrospectives/components/AiGradientIcon.tsx
new file mode 100644
index 0000000..551044f
--- /dev/null
+++ b/src/features/retrospectives/components/AiGradientIcon.tsx
@@ -0,0 +1,59 @@
+import { useId } from 'react'
+
+type AiGradientIconProps = {
+ className?: string
+}
+
+export default function AiGradientIcon({ className = 'size-6' }: AiGradientIconProps) {
+ const gradientId = `${useId()}-ai-gradient`
+
+ return (
+
+ )
+}
diff --git a/src/features/retrospectives/components/AiLoadingOverlay.tsx b/src/features/retrospectives/components/AiLoadingOverlay.tsx
new file mode 100644
index 0000000..1cc57f8
--- /dev/null
+++ b/src/features/retrospectives/components/AiLoadingOverlay.tsx
@@ -0,0 +1,36 @@
+import { createPortal } from 'react-dom'
+
+import { Button } from '@/shared/ui'
+
+import AiGradientIcon from './AiGradientIcon'
+
+type AiLoadingOverlayProps = {
+ isOpen: boolean
+ message?: string
+ onCancel?: () => void
+}
+
+export default function AiLoadingOverlay({
+ isOpen,
+ message = 'AI가 사전 의견을 요약 중이에요',
+ onCancel,
+}: AiLoadingOverlayProps) {
+ if (!isOpen) return null
+
+ return createPortal(
+
+
+
+ {onCancel && (
+
+ )}
+
+
,
+ document.body
+ )
+}
diff --git a/src/features/retrospectives/components/AiSummaryToast.tsx b/src/features/retrospectives/components/AiSummaryToast.tsx
new file mode 100644
index 0000000..73d154f
--- /dev/null
+++ b/src/features/retrospectives/components/AiSummaryToast.tsx
@@ -0,0 +1,67 @@
+import { Check } from 'lucide-react'
+import { useEffect, useRef, useState } from 'react'
+import { createPortal } from 'react-dom'
+
+type AiSummaryToastProps = {
+ isVisible: boolean
+ message?: string
+ onDismiss: () => void
+ duration?: number
+}
+
+export default function AiSummaryToast({
+ isVisible,
+ message = '독서 모임 내용 요약이 완료됐어요',
+ onDismiss,
+ duration = 3000,
+}: AiSummaryToastProps) {
+ const [opacity, setOpacity] = useState(false)
+ const onDismissRef = useRef(onDismiss)
+ const fadeOutTimerRef = useRef | null>(null)
+
+ useEffect(() => {
+ onDismissRef.current = onDismiss
+ })
+
+ useEffect(() => {
+ if (!isVisible) return
+
+ const fadeInTimer = requestAnimationFrame(() => {
+ requestAnimationFrame(() => {
+ setOpacity(true)
+ })
+ })
+
+ const dismissTimer = setTimeout(() => {
+ setOpacity(false)
+ fadeOutTimerRef.current = setTimeout(() => onDismissRef.current(), 300)
+ }, duration)
+
+ return () => {
+ cancelAnimationFrame(fadeInTimer)
+ clearTimeout(dismissTimer)
+ if (fadeOutTimerRef.current) clearTimeout(fadeOutTimerRef.current)
+ setOpacity(false)
+ }
+ }, [isVisible, duration])
+
+ if (!isVisible) return null
+
+ return createPortal(
+ ,
+ document.body
+ )
+}
diff --git a/src/features/retrospectives/components/RetrospectiveCardButtons.tsx b/src/features/retrospectives/components/RetrospectiveCardButtons.tsx
new file mode 100644
index 0000000..d390d09
--- /dev/null
+++ b/src/features/retrospectives/components/RetrospectiveCardButtons.tsx
@@ -0,0 +1,53 @@
+import { useNavigate } from 'react-router-dom'
+
+import { ROUTES } from '@/shared/constants'
+
+interface RetrospectiveCardButtonsProps {
+ gatheringId: number
+ meetingId: number
+}
+
+export default function RetrospectiveCardButtons({
+ gatheringId,
+ meetingId,
+}: RetrospectiveCardButtonsProps) {
+ const navigate = useNavigate()
+
+ return (
+
+ {/* 약속 회고 카드 */}
+
+
+ {/* 개인 회고 카드 */}
+
+
+ )
+}
diff --git a/src/features/retrospectives/components/RetrospectiveSummarySkeleton.tsx b/src/features/retrospectives/components/RetrospectiveSummarySkeleton.tsx
new file mode 100644
index 0000000..f08e760
--- /dev/null
+++ b/src/features/retrospectives/components/RetrospectiveSummarySkeleton.tsx
@@ -0,0 +1,37 @@
+import { Card } from '@/shared/ui'
+
+type RetrospectiveSummarySkeletonProps = {
+ count?: number
+}
+
+export default function RetrospectiveSummarySkeleton({
+ count = 3,
+}: RetrospectiveSummarySkeletonProps) {
+ return (
+
+ {[...Array(count).keys()].map((i) => (
+
+
+ {/* 토픽 헤더 */}
+
+ {/* 요약 텍스트 */}
+
+ {/* 키포인트 */}
+
+
+
+ ))}
+
+ )
+}
diff --git a/src/features/retrospectives/components/index.ts b/src/features/retrospectives/components/index.ts
new file mode 100644
index 0000000..91b716a
--- /dev/null
+++ b/src/features/retrospectives/components/index.ts
@@ -0,0 +1,4 @@
+export { default as AiLoadingOverlay } from './AiLoadingOverlay'
+export { default as AiSummaryToast } from './AiSummaryToast'
+export { default as RetrospectiveCardButtons } from './RetrospectiveCardButtons'
+export { default as RetrospectiveSummarySkeleton } from './RetrospectiveSummarySkeleton'
diff --git a/src/features/retrospectives/index.ts b/src/features/retrospectives/index.ts
new file mode 100644
index 0000000..4ffb8fb
--- /dev/null
+++ b/src/features/retrospectives/index.ts
@@ -0,0 +1,2 @@
+// Components
+export * from './components'
diff --git a/src/pages/Meetings/MeetingDetailPage.tsx b/src/pages/Meetings/MeetingDetailPage.tsx
index 851429a..99ce3b3 100644
--- a/src/pages/Meetings/MeetingDetailPage.tsx
+++ b/src/pages/Meetings/MeetingDetailPage.tsx
@@ -8,6 +8,7 @@ import {
MeetingDetailInfo,
useMeetingDetail,
} from '@/features/meetings'
+import { RetrospectiveCardButtons } from '@/features/retrospectives'
import type {
GetConfirmedTopicsResponse,
GetProposedTopicsResponse,
@@ -115,6 +116,13 @@ export default function MeetingDetailPage() {
{/* 약속 로딩 적용 */}
+ {meeting?.progressStatus === 'POST' && (
+
+ )}
+
주제
()
+
+ if (!gatheringId || !meetingId) return null
+
+ return (
+ <>
+
+
+ {/* 헤더: 타이틀 + 설명 + AI 요약 시작하기 버튼 */}
+
+
+
약속 회고
+
+ 사전 의견과 녹음 파일을 분석하여 약속 회고를 자동 생성해요
+
+
+
+
+
+ {/* 두 패널 영역 */}
+
+ {/* 왼쪽: 수집된 사전 의견 */}
+
+
+
수집된 사전 의견
+
{/* TODO: 사전 의견 카운트 */}0개
+
+ {/* TODO: 사전 의견 멤버 리스트 + 의견 내용 */}
+
+
+ {/* 오른쪽: 녹음 파일 업로드 */}
+
+
+
녹음 파일 업로드
+
AI가 음성을 텍스트로 변환하여 분석해요
+
+ {/* TODO: 파일 업로드 드롭존 + 파일 리스트 테이블 */}
+
+
+
+ {/* TODO: AI 요약 중 모달 */}
+ >
+ )
+}
diff --git a/src/pages/Retrospectives/MeetingRetrospectivePage.tsx b/src/pages/Retrospectives/MeetingRetrospectivePage.tsx
new file mode 100644
index 0000000..c9aac62
--- /dev/null
+++ b/src/pages/Retrospectives/MeetingRetrospectivePage.tsx
@@ -0,0 +1,95 @@
+import { useCallback, useEffect, useState } from 'react'
+import { useLocation, useNavigate, useParams } from 'react-router-dom'
+
+import {
+ AiLoadingOverlay,
+ AiSummaryToast,
+ RetrospectiveSummarySkeleton,
+} from '@/features/retrospectives'
+import SubPageHeader from '@/shared/components/SubPageHeader'
+import { ROUTES } from '@/shared/constants'
+import { Button } from '@/shared/ui'
+
+type MeetingRetrospectiveLocationState = {
+ fromAiSummary?: boolean
+}
+
+export default function MeetingRetrospectivePage() {
+ const navigate = useNavigate()
+ const location = useLocation()
+ const { gatheringId, meetingId } = useParams<{
+ gatheringId: string
+ meetingId: string
+ }>()
+
+ const fromAiSummary =
+ (location.state as MeetingRetrospectiveLocationState)?.fromAiSummary ?? false
+
+ const [showOverlay, setShowOverlay] = useState(fromAiSummary)
+ const [showToast, setShowToast] = useState(false)
+ const [isLoading, setIsLoading] = useState(fromAiSummary)
+
+ // 새로고침 시 오버레이 재표시 방지
+ useEffect(() => {
+ if (fromAiSummary) {
+ window.history.replaceState({}, '')
+ }
+ }, [fromAiSummary])
+
+ // TODO: setTimeout mock → 실제 API 호출로 교체
+ const handleSummaryComplete = useCallback(() => {
+ setShowOverlay(false)
+ setIsLoading(false)
+ setShowToast(true)
+ }, [])
+
+ const handleCancelAiSummary = useCallback(() => {
+ setShowOverlay(false)
+ setIsLoading(false)
+ }, [])
+
+ useEffect(() => {
+ if (!isLoading) return
+
+ const timer = setTimeout(handleSummaryComplete, 3000)
+ return () => clearTimeout(timer)
+ }, [isLoading, handleSummaryComplete])
+
+ if (!gatheringId || !meetingId) return null
+
+ return (
+ <>
+
+
+ {/* 헤더: 약속 회고 타이틀 + 버튼 */}
+
+
약속 회고
+
+
+
+
+
+
+ {/* 회고 콘텐츠 영역 */}
+
+ {isLoading ? (
+
+ ) : (
+ /* TODO: 실제 AI 요약 콘텐츠 렌더링 */
+
회고 콘텐츠가 여기에 표시됩니다.
+ )}
+
+
+
+ setShowToast(false)} />
+ >
+ )
+}
diff --git a/src/pages/Retrospectives/index.ts b/src/pages/Retrospectives/index.ts
new file mode 100644
index 0000000..3db99f7
--- /dev/null
+++ b/src/pages/Retrospectives/index.ts
@@ -0,0 +1,2 @@
+export { default as MeetingRetrospectiveCreatePage } from './MeetingRetrospectiveCreatePage'
+export { default as MeetingRetrospectivePage } from './MeetingRetrospectivePage'
diff --git a/src/pages/index.ts b/src/pages/index.ts
index 0313893..9b42c34 100644
--- a/src/pages/index.ts
+++ b/src/pages/index.ts
@@ -7,4 +7,5 @@ export * from './Landing'
export * from './Meetings'
export * from './PreOpinions'
export * from './Records'
+export * from './Retrospectives'
export * from './Topics'
diff --git a/src/routes/index.tsx b/src/routes/index.tsx
index 87b05fd..72a6d9d 100644
--- a/src/routes/index.tsx
+++ b/src/routes/index.tsx
@@ -15,6 +15,8 @@ import {
LoginPage,
MeetingCreatePage,
MeetingDetailPage,
+ MeetingRetrospectiveCreatePage,
+ MeetingRetrospectivePage,
MeetingSettingPage,
OnboardingPage,
PreOpinionListPage,
@@ -137,6 +139,14 @@ export const router = createBrowserRouter([
path: ROUTES.PRE_OPINIONS(':gatheringId', ':meetingId'),
element: ,
},
+ {
+ path: `${ROUTES.GATHERINGS}/:gatheringId/meetings/:meetingId/retrospective`,
+ element: ,
+ },
+ {
+ path: `${ROUTES.GATHERINGS}/:gatheringId/meetings/:meetingId/retrospective/create`,
+ element: ,
+ },
{
path: `${ROUTES.GATHERINGS}/:gatheringId/meetings/setting`,
element: ,
diff --git a/src/shared/constants/routes.ts b/src/shared/constants/routes.ts
index 01cbcb0..aad78dd 100644
--- a/src/shared/constants/routes.ts
+++ b/src/shared/constants/routes.ts
@@ -38,6 +38,12 @@ export const ROUTES = {
TOPICS_CREATE: (gatheringId: number | string, meetingId: number | string) =>
`/gatherings/${gatheringId}/meetings/${meetingId}/topic-create`,
+ // Retrospectives (약속 회고)
+ MEETING_RETROSPECTIVE: (gatheringId: number | string, meetingId: number | string) =>
+ `/gatherings/${gatheringId}/meetings/${meetingId}/retrospective`,
+ MEETING_RETROSPECTIVE_CREATE: (gatheringId: number | string, meetingId: number | string) =>
+ `/gatherings/${gatheringId}/meetings/${meetingId}/retrospective/create`,
+
// Records
RECORDS: '/records',
RECORD_DETAIL: (id: number | string) => `/records/${id}`,
diff --git a/src/shared/ui/Sonner.tsx b/src/shared/ui/Sonner.tsx
index 8aac70b..c87cd6d 100644
--- a/src/shared/ui/Sonner.tsx
+++ b/src/shared/ui/Sonner.tsx
@@ -7,7 +7,7 @@ const Sonner = ({ ...props }: ToasterProps) => {
gap={12}
visibleToasts={3}
offset={48}
- icons={{ error: ' ' }}
+ icons={{ error: <>> }}
toastOptions={{
unstyled: true,
classNames: {