diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ff1cd18bd..4b6db06ea 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -107,9 +107,9 @@ jobs: - name: Build Storybook run: yarn build-storybook - # Playwright E2E: 스테이징 환경 대상 실행 - # E2E_AUTH_JSON secret 설정 시 @auth 태그 포함 전체 스위트 실행 - # secret 누락/만료 시 비인증 테스트만 실행 (fallback) + # Playwright E2E: + # - non-@auth 테스트: CI 내 로컬 서버(localhost:3000) 대상 항상 실행 (스테이징 의존 없음) + # - @auth 테스트: 스테이징 접속 가능 시에만 실행, 불가 시 경고 후 스킵 # 갱신 절차: yarn e2e:save-auth → GitHub Secret E2E_AUTH_JSON 업데이트 e2e: runs-on: ubuntu-latest @@ -170,15 +170,41 @@ jobs: if: env.E2E_AUTH_JSON == '' run: echo "::warning::E2E_AUTH_JSON not set — skipping @auth tests" - - name: Run E2E tests (full suite, with auth) - if: env.E2E_AUTH_JSON != '' && env.AUTH_EXPIRED != 'true' - run: yarn e2e + - name: Build Next.js app + run: yarn build env: - E2E_BASE_URL: https://test.zeroone.it.kr + NEXT_PUBLIC_API_BASE_URL: https://test-api.zeroone.it.kr - - name: Run E2E tests (non-auth only) - if: env.E2E_AUTH_JSON == '' || env.AUTH_EXPIRED == 'true' + - name: Start local server + run: | + NEXT_PUBLIC_API_BASE_URL=https://test-api.zeroone.it.kr yarn start & + for i in $(seq 1 30); do + if curl -sf --max-time 5 http://localhost:3000/ > /dev/null 2>&1; then + echo "Local server ready" + break + fi + echo "Waiting for server... ($i/30)" + sleep 2 + done + + - name: Run E2E tests (non-auth, local server) run: yarn e2e --grep-invert @auth + env: + E2E_BASE_URL: http://localhost:3000 + + - name: Check staging connectivity + run: | + STATUS=$(curl -s --max-time 10 -o /dev/null -w "%{http_code}" https://test.zeroone.it.kr/ 2>/dev/null || true) + if [[ "$STATUS" =~ ^[2-4][0-9][0-9]$ ]]; then + echo "Staging reachable (HTTP $STATUS)" + else + echo "::warning::Staging https://test.zeroone.it.kr unreachable — skipping @auth tests" + echo "STAGING_DOWN=true" >> "$GITHUB_ENV" + fi + + - name: Run E2E tests (auth suite, staging) + if: env.E2E_AUTH_JSON != '' && env.AUTH_EXPIRED != 'true' && env.STAGING_DOWN != 'true' + run: yarn e2e --grep @auth env: E2E_BASE_URL: https://test.zeroone.it.kr diff --git a/e2e/class/builder-feed.spec.ts b/e2e/class/builder-feed.spec.ts index 4d052ad2b..bed988e99 100644 --- a/e2e/class/builder-feed.spec.ts +++ b/e2e/class/builder-feed.spec.ts @@ -123,7 +123,11 @@ function makeComments(): { content: BuilderFeedCommentsResponse } { { commentId: 1, content: '멋진 피드네요!', - author: { memberId: 3, nickname: '댓글러', role: 'STUDENT' }, + author: { + memberId: 3, + nickname: '댓글러', + role: 'STUDENT', + }, createdAt: '2025-05-01T13:00:00.000Z', replies: [], }, @@ -133,12 +137,27 @@ function makeComments(): { content: BuilderFeedCommentsResponse } { } // ─── Route Mock Helpers ─────────────────────────────────────────────────────── +// +// Use URL-object function predicates (url.pathname) rather than regex strings +// so cross-origin requests to test-api.zeroone.it.kr are matched precisely. +// +// Registration order follows LIFO (last registered = first evaluated), so +// more-specific handlers are registered last. async function mockFeedListApis(page: Page, feeds: FeedItem[] = []) { - await page.route(/\/courses\//, async (route) => { - const url = route.request().url(); - if (url.includes('/courses/vibe-intro/curriculum')) { - await route.fulfill({ + // (1) course detail — single path segment after /courses/ + await page.route( + (url) => /\/api\/v5\/courses\/[^/]+$/.test(url.pathname), + async (route) => route.fulfill({ json: makeCourseDetail() }), + ); + + // (2) curriculum — /courses/{slug}/curriculum + await page.route( + (url) => + url.pathname.startsWith('/api/v5/courses/') && + url.pathname.endsWith('/curriculum'), + async (route) => + route.fulfill({ json: { content: { courseId: COURSE_ID, @@ -148,42 +167,64 @@ async function mockFeedListApis(page: Page, feeds: FeedItem[] = []) { chapters: [], }, }, - }); - } else if (url.includes('/builder-feeds')) { - await route.fulfill({ json: makeFeedList(feeds) }); - } else if (url.includes('/courses/vibe-intro')) { - await route.fulfill({ json: makeCourseDetail() }); - } else { - await route.continue(); - } - }); + }), + ); + + // (3) builder-feeds list — /courses/{courseId}/builder-feeds (checked first via LIFO) + await page.route( + (url) => + url.pathname.startsWith('/api/v5/courses/') && + url.pathname.includes('/builder-feeds'), + async (route) => route.fulfill({ json: makeFeedList(feeds) }), + ); } async function mockFeedDetailApis(page: Page) { - // Intercept builder-feeds/* routes first (more specific) - await page.route(/\/builder-feeds\//, async (route) => { - const url = route.request().url(); - if (url.includes('/comments')) { - await route.fulfill({ json: makeComments() }); - } else if (url.includes('/like')) { + // (1) course detail + await page.route( + (url) => /\/api\/v5\/courses\/[^/]+$/.test(url.pathname), + async (route) => route.fulfill({ json: makeCourseDetail() }), + ); + + // (2) builder-feeds list on course (for "더 많은 피드" section) + await page.route( + (url) => + url.pathname.startsWith('/api/v5/courses/') && + url.pathname.includes('/builder-feeds'), + async (route) => route.fulfill({ json: makeFeedList([]) }), + ); + + // (3) feed detail — /builder-feeds/{id} (exact numeric id, no sub-path) + await page.route( + (url) => /\/api\/v5\/builder-feeds\/\d+$/.test(url.pathname), + async (route) => route.fulfill({ json: makeFeedDetail() }), + ); + + // (4) comments — /builder-feeds/{id}/comments + await page.route( + (url) => + url.pathname.startsWith('/api/v5/builder-feeds/') && + url.pathname.endsWith('/comments'), + async (route) => route.fulfill({ json: makeComments() }), + ); + + // (5) like — POST /builder-feeds/{id}/like (checked first via LIFO) + await page.route( + (url) => + url.pathname.startsWith('/api/v5/builder-feeds/') && + url.pathname.endsWith('/like'), + async (route) => { + if (route.request().method() !== 'POST') { + await route.continue(); + return; + } await route.fulfill({ - json: { content: { feedId: FEED_ID, isLiked: true, likeCount: 6 } }, + json: { + content: { feedId: FEED_ID, isLiked: true, likeCount: 6 }, + }, }); - } else { - await route.fulfill({ json: makeFeedDetail() }); - } - }); - // Intercept /courses/* for "더 많은 피드" and course detail - await page.route(/\/courses\//, async (route) => { - const url = route.request().url(); - if (url.includes('/builder-feeds')) { - await route.fulfill({ json: makeFeedList([]) }); - } else if (url.includes('/courses/vibe-intro')) { - await route.fulfill({ json: makeCourseDetail() }); - } else { - await route.continue(); - } - }); + }, + ); } // ─── Tests ──────────────────────────────────────────────────────────────────── @@ -193,10 +234,13 @@ test.describe('빌더 피드 목록 @auth', () => { page, }) => { await mockFeedListApis(page, [makeFeedItem(FEED_ID)]); - await page.goto(FEED_LIST_PATH, { waitUntil: 'load' }); + await Promise.all([ + page.waitForResponse((r) => /\/courses\/vibe-intro$/.test(r.url())), + page.goto(FEED_LIST_PATH, { waitUntil: 'load' }), + ]); await expect(page.getByText(/테스트 피드 내용/)).toBeVisible({ - timeout: 10000, + timeout: 5000, }); await expect(page.getByText('테스터').first()).toBeVisible(); }); @@ -205,10 +249,13 @@ test.describe('빌더 피드 목록 @auth', () => { page, }) => { await mockFeedListApis(page, []); - await page.goto(FEED_LIST_PATH, { waitUntil: 'load' }); + await Promise.all([ + page.waitForResponse((r) => /\/courses\/vibe-intro$/.test(r.url())), + page.goto(FEED_LIST_PATH, { waitUntil: 'load' }), + ]); await expect(page.getByText('아직 등록된 피드가 없어요.')).toBeVisible({ - timeout: 10000, + timeout: 5000, }); }); }); @@ -219,23 +266,34 @@ test.describe('빌더 피드 상세 @auth', () => { }); test('피드 상세 렌더링 — 내용·댓글 표시', async ({ page }) => { - await page.goto(FEED_DETAIL_PATH, { waitUntil: 'load' }); + await Promise.all([ + page.waitForResponse( + (r) => + /\/builder-feeds\/\d+$/.test(r.url()) && + r.request().method() === 'GET', + ), + page.goto(FEED_DETAIL_PATH, { waitUntil: 'load' }), + ]); await expect(page.getByText('피드 상세 내용입니다.')).toBeVisible({ - timeout: 10000, + timeout: 5000, }); await expect(page.getByText('멋진 피드네요!')).toBeVisible({ - timeout: 5000, + timeout: 10000, }); }); test('좋아요 버튼 클릭 → POST /builder-feeds/{id}/like 호출 확인', async ({ page, }) => { - await page.goto(FEED_DETAIL_PATH, { waitUntil: 'load' }); - await expect(page.getByText('피드 상세 내용입니다.')).toBeVisible({ - timeout: 10000, - }); + await Promise.all([ + page.waitForResponse( + (r) => + /\/builder-feeds\/\d+$/.test(r.url()) && + r.request().method() === 'GET', + ), + page.goto(FEED_DETAIL_PATH, { waitUntil: 'load' }), + ]); // Like button is the first action button (Heart icon + likeCount) const [likeResponse] = await Promise.all([ @@ -243,6 +301,7 @@ test.describe('빌더 피드 상세 @auth', () => { (r) => r.url().includes(`/builder-feeds/${FEED_ID}/like`) && r.request().method() === 'POST', + { timeout: 15000 }, ), page.locator('button').filter({ hasText: '5' }).first().click(), ]); diff --git a/public/my-page/discord-icon.png b/public/my-page/discord-icon.png new file mode 100644 index 000000000..802a0a719 Binary files /dev/null and b/public/my-page/discord-icon.png differ diff --git a/public/my-page/feed-icon.svg b/public/my-page/feed-icon.svg new file mode 100644 index 000000000..b22fc9e26 --- /dev/null +++ b/public/my-page/feed-icon.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/api/client/axios.ts b/src/api/client/axios.ts index 5e8f9f97d..c1c46f7fb 100644 --- a/src/api/client/axios.ts +++ b/src/api/client/axios.ts @@ -82,3 +82,22 @@ axiosInstanceForMultipartV5.interceptors.response.use( axiosInstanceForMultipartV5(requestConfig), ), ); + +// v6 MyPage API 전용 인스턴스 +export const axiosInstanceV6 = axios.create({ + baseURL: `${process.env.NEXT_PUBLIC_API_BASE_URL}/api/v6/`, + timeout: 60000, + headers: { + 'Content-Type': 'application/json', + }, + withCredentials: true, +}); + +attachApiLogger(axiosInstanceV6, 'client-v6-json'); +axiosInstanceV6.interceptors.request.use(attachAccessTokenToRequest); +axiosInstanceV6.interceptors.response.use( + (config) => config, + createClientAuthResponseRejectedHandler((requestConfig) => + axiosInstanceV6(requestConfig), + ), +); diff --git a/src/app/(class-lesson)/class/[slug]/lesson/[id]/_components/lesson-qna-submission-modal.tsx b/src/app/(class-lesson)/class/[slug]/lesson/[id]/_components/lesson-qna-submission-modal.tsx index 916d9d5e2..b6cfb563a 100644 --- a/src/app/(class-lesson)/class/[slug]/lesson/[id]/_components/lesson-qna-submission-modal.tsx +++ b/src/app/(class-lesson)/class/[slug]/lesson/[id]/_components/lesson-qna-submission-modal.tsx @@ -286,11 +286,11 @@ export function LessonQnaSubmissionModal({ {/* Footer */} -
+
@@ -299,7 +299,7 @@ export function LessonQnaSubmissionModal({ onClick={handleSubmit} disabled={createQna.isPending} className={cn( - 'w-full rounded-100 px-400 py-200 font-designer-18b text-text-inverse transition-opacity', + 'flex h-700 flex-1 items-center justify-center rounded-100 font-designer-18b text-text-inverse transition-opacity', createQna.isPending ? 'cursor-not-allowed bg-gray-300' : 'bg-rose-500 hover:opacity-90', diff --git a/src/app/(landing)/class/[slug]/(learning)/feed/write/page.tsx b/src/app/(landing)/class/[slug]/(learning)/feed/write/page.tsx index ff4417f9c..e44cbd27b 100644 --- a/src/app/(landing)/class/[slug]/(learning)/feed/write/page.tsx +++ b/src/app/(landing)/class/[slug]/(learning)/feed/write/page.tsx @@ -18,6 +18,11 @@ import { import { useToastStore } from '@/stores/use-toast-store'; import { extractPlainTextFromHtml } from '@/utils/markdown-content'; +const FEED_NOTICE = [ + '작성한 피드는 언제든지 수정하거나 삭제할 수 있습니다.', + '다른 수강생에게 불쾌감을 줄 수 있는 내용은 운영 정책에 따라 삭제될 수 있습니다.', +]; + interface AttachedImage { previewUrl: string; key: string; @@ -40,11 +45,11 @@ export default function FeedWritePage() { const [selectedLessonId, setSelectedLessonId] = useState(null); const [lessonOpen, setLessonOpen] = useState(false); const [text, setText] = useState(''); - const [showCancelModal, setShowCancelModal] = useState(false); const [images, setImages] = useState([]); const [isUploadingImage, setIsUploadingImage] = useState(false); const [initialized, setInitialized] = useState(false); const fileInputRef = useRef(null); + const draftFeedIdRef = useRef(null); const { data: courseData } = useGetCourseDetail(slug); const courseId = courseData?.courseId ?? 0; @@ -66,6 +71,20 @@ export default function FeedWritePage() { setInitialized(true); }, [existingFeed, initialized, isEditMode]); + useEffect(() => { + if (isEditMode) return; + const draft = localStorage.getItem(`course-feed-draft-${slug}`); + if (!draft) return; + try { + const { content: c, lessonId: l, feedId: f } = JSON.parse(draft); + if (c) setText(c); + if (l) setSelectedLessonId(l); + if (typeof f === 'number') draftFeedIdRef.current = f; + } catch { + // malformed draft — ignore + } + }, [slug, isEditMode]); + const allLessons = curriculum?.chapters.flatMap((ch) => ch.lessons.map((l) => ({ @@ -107,6 +126,57 @@ export default function FeedWritePage() { }); } + function handleSaveDraft() { + if (!selectedLessonId) { + showToast('레슨을 선택해주세요.', 'error'); + return; + } + if (!extractPlainTextFromHtml(text)) { + showToast('내용을 입력해주세요.', 'error'); + return; + } + const persistDraft = (feedId: number) => { + draftFeedIdRef.current = feedId; + localStorage.setItem( + `course-feed-draft-${slug}`, + JSON.stringify({ content: text, lessonId: selectedLessonId, feedId }), + ); + showToast('임시저장되었어요.'); + }; + if (draftFeedIdRef.current) { + updateFeed.mutate( + { + feedId: draftFeedIdRef.current, + request: { + content: text, + imageKeys: images.map((img) => img.key), + status: 'DRAFT', + }, + }, + { + onSuccess: () => persistDraft(draftFeedIdRef.current!), + onError: () => showToast('임시저장에 실패했어요.', 'error'), + }, + ); + } else { + createFeed.mutate( + { + courseId, + request: { + lessonId: selectedLessonId, + content: text, + imageKeys: images.map((img) => img.key), + status: 'DRAFT', + }, + }, + { + onSuccess: (data) => persistDraft(data.feedId), + onError: () => showToast('임시저장에 실패했어요.', 'error'), + }, + ); + } + } + function handleSubmit() { if (isEditMode) { if (!extractPlainTextFromHtml(text)) { @@ -119,6 +189,7 @@ export default function FeedWritePage() { request: { content: text, imageKeys: images.map((i) => i.key), + status: 'PUBLISHED', }, }, { @@ -138,23 +209,43 @@ export default function FeedWritePage() { showToast('내용을 입력해주세요.', 'error'); return; } - createFeed.mutate( - { - courseId, - request: { - lessonId: selectedLessonId, - content: text, - imageKeys: images.map((img) => img.key), + const navigateToPublished = (feedId: number) => { + localStorage.removeItem(`course-feed-draft-${slug}`); + showToast('피드가 등록되었어요!'); + router.push(`/class/${slug}/feed/${feedId}`); + }; + if (draftFeedIdRef.current) { + const feedId = draftFeedIdRef.current; + updateFeed.mutate( + { + feedId, + request: { + content: text, + imageKeys: images.map((img) => img.key), + status: 'PUBLISHED', + }, }, - }, - { - onSuccess: () => { - showToast('피드가 등록되었어요!'); - router.push(`/class/${slug}/home?tab=feed`); + { + onSuccess: () => navigateToPublished(feedId), + onError: () => showToast('등록에 실패했어요.', 'error'), }, - onError: () => showToast('등록에 실패했어요.', 'error'), - }, - ); + ); + } else { + createFeed.mutate( + { + courseId, + request: { + lessonId: selectedLessonId, + content: text, + imageKeys: images.map((img) => img.key), + }, + }, + { + onSuccess: (data) => navigateToPublished(data.feedId), + onError: () => showToast('등록에 실패했어요.', 'error'), + }, + ); + } } } @@ -165,41 +256,10 @@ export default function FeedWritePage() { const submitLabel = isEditMode ? '수정하기' : '등록하기'; const submitPending = isEditMode ? updateFeed.isPending - : createFeed.isPending; + : createFeed.isPending || updateFeed.isPending; return ( <> - {/* Cancel confirmation modal — create mode only */} - {showCancelModal && ( -
-
-
-

- 피드 등록을 취소하시겠습니까? -

-

- 작성된 내용은 저장되지 않습니다. -

-
-
- - - 확인 - -
-
-
- )} -
(
fileInputRef.current?.click()} className={cn( - 'flex h-1625 w-1625 shrink-0 flex-col items-center justify-center gap-75 rounded-150 border border-border-default bg-gray-200', + 'flex h-1500 w-1500 shrink-0 flex-col items-center justify-center gap-75 rounded-150 border border-border-default bg-gray-200', isUploadingImage ? 'cursor-not-allowed opacity-50' : 'hover:border-rose-400', @@ -354,6 +414,16 @@ export default function FeedWritePage() { uploadImage={uploadCommunityMarkdownImage} /> + {/* Notice */} +
+

유의사항

+
    + {FEED_NOTICE.map((item) => ( +
  • {item}
  • + ))} +
+
+ {/* CTAs */}
{isEditMode ? ( @@ -362,15 +432,16 @@ export default function FeedWritePage() { onClick={() => router.push(`/class/${slug}/feed/${editFeedId}`) } - className="flex h-700 flex-1 items-center justify-center rounded-100 border border-border-default font-designer-16m text-gray-800" + className="flex h-775 flex-1 items-center justify-center rounded-100 border border-border-default font-designer-16m text-gray-800" > 취소 ) : ( @@ -379,7 +450,7 @@ export default function FeedWritePage() { type="button" onClick={handleSubmit} disabled={submitPending} - className="flex h-700 flex-1 items-center justify-center rounded-100 bg-background-brand-default font-designer-16m text-text-inverse disabled:bg-gray-300" + className="flex h-775 flex-1 items-center justify-center rounded-100 bg-background-brand-default font-designer-16m text-text-inverse disabled:bg-gray-300" > {submitLabel} diff --git a/src/app/(landing)/class/[slug]/(learning)/qa/write/page.tsx b/src/app/(landing)/class/[slug]/(learning)/qa/write/page.tsx index 4980c7607..d046c42bf 100644 --- a/src/app/(landing)/class/[slug]/(learning)/qa/write/page.tsx +++ b/src/app/(landing)/class/[slug]/(learning)/qa/write/page.tsx @@ -4,7 +4,7 @@ import { ArrowLeft, ChevronDown, Plus, X } from 'lucide-react'; import Image from 'next/image'; import Link from 'next/link'; import { useParams, useRouter } from 'next/navigation'; -import { useRef, useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { cn } from '@/components/common/ui/(shadcn)/lib/utils'; import MarkdownEditor from '@/components/common/ui/editor/markdown-editor'; import { uploadCommunityMarkdownImage } from '@/features/community/model/community-markdown-image-upload'; @@ -15,6 +15,11 @@ import { } from '@/hooks/queries/course/course-api'; import { useToastStore } from '@/stores/use-toast-store'; +const QNA_NOTICE = [ + '질문 후 답변이 달리기 전까지는 수정 및 삭제가 자유롭습니다.', + '답변을 받은 후에는 수정 및 삭제가 어려우니 유의해주시기 바랍니다.', +]; + interface AttachedImage { previewUrl: string; key: string; @@ -27,7 +32,6 @@ export default function QnaWritePage() { const [selectedLessonId, setSelectedLessonId] = useState(null); const [lessonOpen, setLessonOpen] = useState(false); const [text, setText] = useState(''); - const [showCancelModal, setShowCancelModal] = useState(false); const [images, setImages] = useState([]); const [isUploadingImage, setIsUploadingImage] = useState(false); const fileInputRef = useRef(null); @@ -37,6 +41,18 @@ export default function QnaWritePage() { const { data: curriculum } = useGetCourseCurriculum(slug); const createQna = useCreateLessonQna(); + useEffect(() => { + const draft = localStorage.getItem(`course-qna-draft-${slug}`); + if (!draft) return; + try { + const { content: c, lessonId: l } = JSON.parse(draft); + if (c) setText(c); + if (l) setSelectedLessonId(l); + } catch { + // malformed draft — ignore + } + }, [slug]); + const allLessons = curriculum?.chapters.flatMap((ch) => ch.lessons.map((l) => ({ @@ -76,6 +92,18 @@ export default function QnaWritePage() { }); } + function handleSaveDraft() { + if (!selectedLessonId) { + showToast('레슨을 선택해주세요.', 'error'); + return; + } + localStorage.setItem( + `course-qna-draft-${slug}`, + JSON.stringify({ content: text, lessonId: selectedLessonId }), + ); + showToast('임시저장되었습니다.'); + } + function handleSubmit() { if (!selectedLessonId) { showToast('레슨을 선택해주세요.', 'error'); @@ -96,6 +124,7 @@ export default function QnaWritePage() { }, { onSuccess: () => { + localStorage.removeItem(`course-qna-draft-${slug}`); showToast('질문이 등록되었어요!'); router.push(`/class/${slug}/home?tab=qna`); }, @@ -105,175 +134,152 @@ export default function QnaWritePage() { } return ( - <> - {showCancelModal && ( -
-
-
-

질문 등록을 취소하시겠습니까?

-

작성된 내용은 저장되지 않습니다.

+
+
+ + + 질문 목록 돌아가기 + + +
+ {/* Lesson selector */} +
+

+ 어떤 레슨에 대한 질문인가요? +

+
+ 바이브 코딩 입문자 코스
-
+
- - 확인 - -
-
-
- )} - -
-
- - - 질문 목록 돌아가기 - - -
- {/* Course display (fixed) */} -
-

- 어떤 레슨에 대한 질문인가요? -

-
- 바이브 코딩 입문자 코스 -
- - {/* Lesson selector */} -
- - {lessonOpen && ( -
- {allLessons.map((l) => ( - - ))} -
- )} -
-
- - {/* Image upload */} -
-

- * 최대 10개의 사진을 등록할 수 있어요. -

-
- {images.map((img, i) => ( -
- {`첨부 + {lessonOpen && ( +
+ {allLessons.map((l) => ( -
- ))} - {images.length < 10 && ( + ))} +
+ )} +
+
+ + {/* Image upload */} +
+

+ * 최대 10개의 사진을 등록할 수 있어요. +

+
+ {images.map((img, i) => ( +
+ {`첨부 - )} -
- { - const target = e.target; - const file = target.files?.[0]; - if (file) await handleImageAdd(file); - target.value = ''; - }} - /> +
+ ))} + {images.length < 10 && ( + + )}
- - {/* Text content */} - { + const target = e.target; + const file = target.files?.[0]; + if (file) await handleImageAdd(file); + target.value = ''; + }} /> +
- {/* CTAs */} -
- - -
+ {/* Text content */} + + + {/* Notice */} +
+

유의사항

+
    + {QNA_NOTICE.map((item) => ( +
  • {item}
  • + ))} +
+
+ + {/* CTAs */} +
+ +
- +
); } diff --git a/src/app/(service)/(my)/class-payment-management/page.tsx b/src/app/(service)/(my)/class-payment-management/page.tsx index e0a449fa7..630b738ef 100644 --- a/src/app/(service)/(my)/class-payment-management/page.tsx +++ b/src/app/(service)/(my)/class-payment-management/page.tsx @@ -2,10 +2,14 @@ import dynamic from 'next/dynamic'; import { useRouter } from 'next/navigation'; -import { useState } from 'react'; +import { useEffect, useState } from 'react'; import type { VirtualAccountInfo } from '@/api/openapi/models'; import { cn } from '@/components/common/ui/(shadcn)/lib/utils'; -import { useGetMyCoursePayments } from '@/hooks/queries/course/course-api'; +import Badge from '@/components/common/ui/badge'; +import { + useGetMyCoursePaymentDetail, + useGetMyCoursePayments, +} from '@/hooks/queries/course/course-api'; import type { MyCoursePaymentListItemResponse } from '@/types/api/course.types'; const ClassCancelPaymentModal = dynamic( @@ -13,6 +17,11 @@ const ClassCancelPaymentModal = dynamic( { ssr: false }, ); +const ClassRefundRequestModal = dynamic( + () => import('@/components/payment/modals/class-refund-request-modal'), + { ssr: false }, +); + const VirtualAccountInfoModal = dynamic( () => import('@/components/payment/modals/virtual-account-info-modal'), { ssr: false }, @@ -23,32 +32,17 @@ type PeriodMonths = 1 | 3 | 6 | 12; const STATUS_CONFIG: Record< MyCoursePaymentListItemResponse['status'], - { label: string; className: string } + { + label: string; + color: 'blue' | 'orange' | 'green' | 'red' | 'gray'; + } > = { - REQUESTED: { - label: '결제 요청', - className: 'bg-blue-50 text-blue-600', - }, - PENDING: { - label: '결제 중', - className: 'bg-blue-50 text-blue-600', - }, - WAITING_FOR_DEPOSIT: { - label: '입금 대기', - className: 'bg-yellow-50 text-yellow-600', - }, - SUCCESS: { - label: '결제 완료', - className: 'bg-green-50 text-green-600', - }, - FAILED: { - label: '결제 실패', - className: 'bg-red-50 text-red-600', - }, - CANCELED: { - label: '취소 완료', - className: 'bg-gray-100 text-gray-500', - }, + REQUESTED: { label: '결제 요청', color: 'blue' }, + PENDING: { label: '결제 중', color: 'blue' }, + WAITING_FOR_DEPOSIT: { label: '입금 대기', color: 'orange' }, + SUCCESS: { label: '결제 완료', color: 'green' }, + FAILED: { label: '결제 실패', color: 'red' }, + CANCELED: { label: '취소 완료', color: 'gray' }, }; function filterByPeriod( @@ -86,11 +80,31 @@ export default function ClassPaymentManagementPage() { paymentId: number; paymentMethod: 'CARD' | 'VIRTUAL_ACCOUNT'; } | null>(null); + const [refundModal, setRefundModal] = useState<{ paymentId: number } | null>( + null, + ); const [vaModal, setVaModal] = useState(null); + const [selectedVaPaymentId, setSelectedVaPaymentId] = useState( + null, + ); const { data: allPayments = [], isLoading } = useGetMyCoursePayments(); const { data: canceledPayments = [], isLoading: canceledLoading } = useGetMyCoursePayments({ status: 'CANCELED' }); + const { data: vaDetail } = useGetMyCoursePaymentDetail( + selectedVaPaymentId ?? 0, + { enabled: !!selectedVaPaymentId }, + ); + + useEffect(() => { + if (!vaDetail) return; + setVaModal({ + bankName: vaDetail.virtualBankName ?? '-', + accountNumber: vaDetail.virtualAccountNumber ?? '-', + customerName: vaDetail.virtualAccountHolderName ?? undefined, + dueDate: vaDetail.virtualAccountDueDate ?? undefined, + }); + }, [vaDetail]); const rawList = activeTab === 'refunds' ? canceledPayments : allPayments; const filtered = filterByPeriod(rawList, periodMonths); @@ -110,7 +124,7 @@ export default function ClassPaymentManagementPage() { @@ -186,15 +200,12 @@ export default function ClassPaymentManagementPage() { paymentMethod: payment.paymentMethod, }) } - onViewVirtualAccount={() => { - if (!payment.virtualAccountNumber) return; - setVaModal({ - bankName: '-', - accountNumber: payment.virtualAccountNumber, - customerName: undefined, - dueDate: payment.virtualAccountDueDate ?? undefined, - }); - }} + onRequestRefund={() => + setRefundModal({ paymentId: payment.paymentId }) + } + onViewVirtualAccount={(paymentId) => + setSelectedVaPaymentId(paymentId) + } /> ))}
@@ -210,11 +221,24 @@ export default function ClassPaymentManagementPage() { /> )} + {refundModal && ( + !open && setRefundModal(null)} + /> + )} + {vaModal && ( !open && setVaModal(null)} + onOpenChange={(open) => { + if (!open) { + setVaModal(null); + setSelectedVaPaymentId(null); + } + }} /> )}
@@ -224,11 +248,13 @@ export default function ClassPaymentManagementPage() { function PaymentCard({ payment, onCancel, + onRequestRefund, onViewVirtualAccount, }: { payment: MyCoursePaymentListItemResponse; onCancel: () => void; - onViewVirtualAccount: () => void; + onRequestRefund: () => void; + onViewVirtualAccount: (paymentId: number) => void; }) { const status = STATUS_CONFIG[payment.status]; const dateStr = formatDate(payment.paidAt ?? payment.createdAt); @@ -236,19 +262,12 @@ function PaymentCard({ return (
{/* 썸네일 */} -
+
{/* 내용 */}
- - {status.label} - + {status.label} {dateStr}
@@ -277,21 +296,32 @@ function PaymentCard({ )} - {payment.cancellable && ( + {payment.canRequestRefund && ( )} + + {(payment.canCancelPayment ?? payment.cancellable) && + !payment.canRequestRefund && ( + + )}
); diff --git a/src/app/(service)/(my)/my-class/_components/disable-notification-modal.tsx b/src/app/(service)/(my)/my-class/_components/disable-notification-modal.tsx new file mode 100644 index 000000000..ed92e0359 --- /dev/null +++ b/src/app/(service)/(my)/my-class/_components/disable-notification-modal.tsx @@ -0,0 +1,56 @@ +'use client'; + +import Button from '@/components/common/ui/button'; +import { Modal } from '@/components/common/ui/modal'; + +interface DisableNotificationModalProps { + open: boolean; + onOpenChange: (open: boolean) => void; + onConfirm: () => void; +} + +export default function DisableNotificationModal({ + open, + onOpenChange, + onConfirm, +}: DisableNotificationModalProps) { + return ( + + + + + + 알림톡 끄기 + + +

+ 학습 알림톡을 끄면 매일 설정한 시간에 알림을 받지 못합니다. +
+ 정말 끄시겠습니까? +

+
+ +
+ + +
+
+
+
+
+ ); +} diff --git a/src/app/(service)/(my)/my-class/_components/learning-notification-modal.tsx b/src/app/(service)/(my)/my-class/_components/learning-notification-modal.tsx index e4a105151..791093158 100644 --- a/src/app/(service)/(my)/my-class/_components/learning-notification-modal.tsx +++ b/src/app/(service)/(my)/my-class/_components/learning-notification-modal.tsx @@ -12,6 +12,7 @@ import { useToastStore } from '@/stores/use-toast-store'; interface LearningNotificationModalProps { open: boolean; onOpenChange: (open: boolean) => void; + onSuccess?: () => void; } const HOURS = Array.from({ length: 24 }, (_, i) => i); @@ -24,6 +25,7 @@ function pad(n: number) { export default function LearningNotificationModal({ open, onOpenChange, + onSuccess, }: LearningNotificationModalProps) { const showToast = useToastStore((s) => s.showToast); const { data: setting } = useGetNotificationSetting(); @@ -46,6 +48,7 @@ export default function LearningNotificationModal({ }); showToast('알림 시간이 저장되었습니다.', 'success'); onOpenChange(false); + onSuccess?.(); } catch { showToast('알림 시간 저장에 실패했습니다.', 'error'); } diff --git a/src/app/(service)/(my)/my-class/page.tsx b/src/app/(service)/(my)/my-class/page.tsx index 57d9c5ca6..84b6e2209 100644 --- a/src/app/(service)/(my)/my-class/page.tsx +++ b/src/app/(service)/(my)/my-class/page.tsx @@ -3,14 +3,15 @@ import dynamic from 'next/dynamic'; import Image from 'next/image'; import Link from 'next/link'; -import { useState } from 'react'; +import { useEffect, useState } from 'react'; import { cn } from '@/components/common/ui/(shadcn)/lib/utils'; +import { ToggleSwitch } from '@/components/common/ui/toggle/switch'; import { useGetCourseJourneyMap, useGetCourseList, - useGetMyGiftEmail, } from '@/hooks/queries/course/course-api'; import { useGetNotificationSetting } from '@/hooks/queries/notification/use-notification-setting'; +import { useToastStore } from '@/stores/use-toast-store'; import type { CourseSummaryResponse } from '@/types/api/course.types'; const LearningNotificationModal = dynamic( @@ -18,21 +19,49 @@ const LearningNotificationModal = dynamic( { ssr: false }, ); +const DisableNotificationModal = dynamic( + () => import('./_components/disable-notification-modal'), + { ssr: false }, +); + +const NOTIFICATION_DISABLE_KEY = 'zeroone:notification:disabled'; + function pad(n: number) { return String(n).padStart(2, '0'); } export default function MyClassPage() { const { data: courses = [] } = useGetCourseList(); - const { data: giftEmail } = useGetMyGiftEmail(); const { data: notificationSetting, isError: isNotificationSettingError, isLoading: isNotificationSettingLoading, } = useGetNotificationSetting(); const [alarmModalOpen, setAlarmModalOpen] = useState(false); + const [disableModalOpen, setDisableModalOpen] = useState(false); + const [locallyDisabled, setLocallyDisabled] = useState(false); + + useEffect(() => { + setLocallyDisabled( + localStorage.getItem(NOTIFICATION_DISABLE_KEY) === 'true', + ); + }, []); + const showToast = useToastStore((s) => s.showToast); - const isEnabled = notificationSetting?.isEnabled ?? false; + const isEnabled = + (notificationSetting?.isEnabled ?? false) && !locallyDisabled; + + const handleDisableConfirm = () => { + localStorage.setItem(NOTIFICATION_DISABLE_KEY, 'true'); + setLocallyDisabled(true); + setDisableModalOpen(false); + showToast('알림톡이 해제되었습니다.', 'success'); + }; + + const handleAlarmSuccess = () => { + localStorage.removeItem(NOTIFICATION_DISABLE_KEY); + setLocallyDisabled(false); + }; return (
@@ -42,7 +71,18 @@ export default function MyClassPage() {

알림

- setAlarmModalOpen(true)} /> + { + if (checked) { + setAlarmModalOpen(true); + } else { + setDisableModalOpen(true); + } + }} + size="lg" + className="bg-border-subtle data-[state=checked]:bg-fill-brand-default-default" + />
@@ -54,16 +94,19 @@ export default function MyClassPage() { isError={isNotificationSettingError} onOpenModal={() => setAlarmModalOpen(true)} /> -
+ +
@@ -96,34 +139,6 @@ export default function MyClassPage() { ); } -function Toggle({ - enabled, - onClick, -}: { - enabled: boolean; - onClick: () => void; -}) { - return ( - - ); -} - function AlarmCard({ isEnabled, notifyHour, @@ -153,8 +168,8 @@ function AlarmCard({ className={cn( 'flex flex-col gap-200 rounded-200 border p-400', isEnabled - ? 'border-primary-500' - : 'border-border-subtle bg-background-alternative', + ? 'border-border-brand' + : 'border-border-default bg-background-alternative', )} >
@@ -165,7 +180,7 @@ function AlarmCard({ xmlns="http://www.w3.org/2000/svg" className={cn( 'size-300', - isEnabled ? 'text-text-default' : 'text-text-subtle', + isEnabled ? 'text-text-default' : 'text-text-subtlest', )} > @@ -174,7 +189,7 @@ function AlarmCard({

매일 학습 알림톡 시간 @@ -182,7 +197,7 @@ function AlarmCard({

학습이 가장 잘 챙겨지는 시간으로 알림톡을 받아보세요. @@ -191,13 +206,13 @@ function AlarmCard({

{timeText} @@ -206,11 +221,12 @@ function AlarmCard({

@@ -69,11 +66,17 @@ export default function MyInquiryPage() {
{/* 문의 목록 */} - {isLoading ? ( + {isError ? ( +
+

+ 문의 내역을 불러오지 못했습니다. +

+
+ ) : isLoading ? (

불러오는 중...

- ) : inquiries.length === 0 ? ( + ) : !inquiries || inquiries.length === 0 ? (

아직 작성한 문의가 없어요 @@ -85,7 +88,7 @@ export default function MyInquiryPage() { ) : (

{inquiries.map((inquiry) => ( - + ))}
)} @@ -93,9 +96,12 @@ export default function MyInquiryPage() { ); } -function InquiryCard({ inquiry }: { inquiry: InquiryItem }) { +function InquiryCard({ inquiry }: { inquiry: OneToOneInquiryListItem }) { const [expanded, setExpanded] = useState(false); - const status = STATUS_CONFIG[inquiry.status]; + const { data: detail, isLoading: detailLoading } = + useGetMyOneToOneInquiryDetail(expanded ? inquiry.oneToOneInquiryId : null); + + const status = STATUS_CONFIG[inquiry.inquiryStatus] ?? DEFAULT_STATUS; return (
@@ -103,11 +109,13 @@ function InquiryCard({ inquiry }: { inquiry: InquiryItem }) { type="button" onClick={() => setExpanded((prev) => !prev)} aria-expanded={expanded} - aria-controls={`inquiry-panel-${inquiry.id}`} + aria-controls={`inquiry-panel-${inquiry.oneToOneInquiryId}`} className="flex w-full items-start justify-between gap-200 p-300 text-left" >
-

{inquiry.title}

+

+ {inquiry.inquiryPreviewText} +

-
- - 문의 내용 - -

- {inquiry.content} -

-
- - {inquiry.answer && ( -
- 답변 내용 -

- {inquiry.answer} -

- {inquiry.answeredAt && ( -

- {formatDate(inquiry.answeredAt)} + {detailLoading ? ( +

불러오는 중...

+ ) : detail ? ( + <> +
+ + 문의 내용 + +

+ {detail.inquiryContent}

+
+ + {detail.replies.length > 0 && ( +
+ {detail.replies.map((reply) => ( +
+ + 답변 내용 + +

+ {reply.replyContent} +

+

+ {formatDate(reply.createdAt)} +

+
+ ))} +
)} -
- )} + + ) : null}
)}
diff --git a/src/app/(service)/(my)/my-inquiry/write/page.tsx b/src/app/(service)/(my)/my-inquiry/write/page.tsx index 4051a5c23..2fd00d046 100644 --- a/src/app/(service)/(my)/my-inquiry/write/page.tsx +++ b/src/app/(service)/(my)/my-inquiry/write/page.tsx @@ -1,37 +1,39 @@ 'use client'; -import { ArrowLeft, ImagePlus } from 'lucide-react'; +import { ArrowLeft } from 'lucide-react'; import { useRouter } from 'next/navigation'; -import { useRef, useState } from 'react'; +import { useState } from 'react'; import Button from '@/components/common/ui/button'; import { Modal } from '@/components/common/ui/modal'; +import { + useCreateMyOneToOneInquiry, + useSaveDraftOneToOneInquiry, + type InquiryCategory, +} from '@/hooks/queries/my-inquiry/inquiry-api'; import { useToastStore } from '@/stores/use-toast-store'; - -// TODO: 1:1 문의 작성 API not yet available — wire POST /api/v1/inquiries when backend adds it -type InquiryType = '결제' | '클래스' | '운영' | '건의' | '고민'; - -const INQUIRY_TYPES: InquiryType[] = ['결제', '클래스', '운영', '건의', '고민']; +import { + INQUIRY_CATEGORIES, + INQUIRY_CATEGORY_LABELS, +} from '@/types/schemas/inquiry.schema'; export default function MyInquiryWritePage() { const router = useRouter(); const showToast = useToastStore((state) => state.showToast); - const fileInputRef = useRef(null); - - const [type, setType] = useState(''); - const [title, setTitle] = useState(''); + const [category, setCategory] = useState(''); const [content, setContent] = useState(''); - const [images, setImages] = useState([]); const [notifyEmail, setNotifyEmail] = useState(false); const [notifyKakao, setNotifyKakao] = useState(false); const [cancelDialogOpen, setCancelDialogOpen] = useState(false); - const [isSubmitting, setIsSubmitting] = useState(false); + + const { mutateAsync: createInquiry, isPending: isSubmitting } = + useCreateMyOneToOneInquiry(); + const { mutateAsync: saveDraft, isPending: isSavingDraft } = + useSaveDraftOneToOneInquiry(); const handleBack = () => { const hasDraft = - Boolean(type) || - Boolean(title.trim()) || + Boolean(category) || Boolean(content.trim()) || - images.length > 0 || notifyEmail || notifyKakao; if (hasDraft) { @@ -41,41 +43,47 @@ export default function MyInquiryWritePage() { } }; - const handleImageChange = (e: React.ChangeEvent) => { - const files = Array.from(e.target.files ?? []); - setImages((prev) => [...prev, ...files].slice(0, 5)); - e.target.value = ''; - }; - - const handleDraft = () => { - // TODO: POST /api/v1/inquiries/draft - showToast('임시저장 기능은 준비 중입니다.', 'error'); + const handleDraft = async () => { + if (!category && !content.trim()) { + showToast('내용을 입력 후 임시저장할 수 있습니다.', 'error'); + return; + } + try { + await saveDraft({ + id: 0, + request: { + inquiryCategory: category || undefined, + inquiryContent: content.trim() || undefined, + }, + }); + showToast('임시저장되었습니다.', 'success'); + } catch { + showToast('임시저장에 실패했습니다.', 'error'); + } }; const handleSubmit = async () => { - if (!type) { + if (!category) { showToast('문의 유형을 선택해 주세요.', 'error'); return; } - if (!title.trim()) { - showToast('제목을 입력해 주세요.', 'error'); - return; - } if (!content.trim()) { showToast('내용을 입력해 주세요.', 'error'); return; } - setIsSubmitting(true); try { - // TODO: POST /api/v1/inquiries { type, title, content, imageKeys, notifyEmail, notifyKakao } - await new Promise((resolve) => setTimeout(resolve, 500)); + await createInquiry({ + inquiryCategory: category, + inquiryContent: content.trim(), + inquiryAttachmentKeys: [], + replyEmailOptIn: notifyEmail, + replyAlerttalkOptIn: notifyKakao, + }); showToast('문의가 등록되었습니다.', 'success'); router.push('/my-inquiry'); } catch { showToast('문의 등록에 실패했습니다.', 'error'); - } finally { - setIsSubmitting(false); } }; @@ -102,34 +110,21 @@ export default function MyInquiryWritePage() { 유형 선택 *
- {/* 제목 */} -
- - setTitle(e.target.value)} - maxLength={100} - placeholder="제목을 입력해 주세요." - className="border-border-default rounded-100 font-designer-14r text-text-default placeholder:text-text-subtlest border px-200 py-150 outline-none" - /> -
- {/* 내용 */}
- {/* 이미지 첨부 */} -
- - - - {images.length > 0 && ( -
- {images.map((file, index) => ( -
- - {file.name} - - -
- ))} -
- )} -
- {/* 알림 설정 */}