diff --git a/.github/workflows/deploy-prod.yml b/.github/workflows/deploy-prod.yml index 5499beb40..1e7d17ae4 100644 --- a/.github/workflows/deploy-prod.yml +++ b/.github/workflows/deploy-prod.yml @@ -97,6 +97,7 @@ jobs: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_PASSWORD }} + - name: Docker image build and push - name: Docker image build and push run: | set -euo pipefail @@ -108,12 +109,22 @@ jobs: docker push "${{ steps.meta.outputs.frontend_image }}" docker push "${FRONTEND_IMAGE_REPOSITORY}:prod" docker push "${FRONTEND_IMAGE_REPOSITORY}:latest-prod" + set -euo pipefail + docker build -f Dockerfile.prod \ + -t "${{ steps.meta.outputs.frontend_image }}" \ + -t "${FRONTEND_IMAGE_REPOSITORY}:prod" \ + -t "${FRONTEND_IMAGE_REPOSITORY}:latest-prod" \ + . + docker push "${{ steps.meta.outputs.frontend_image }}" + docker push "${FRONTEND_IMAGE_REPOSITORY}:prod" + docker push "${FRONTEND_IMAGE_REPOSITORY}:latest-prod" + deploy-image-to-server: deploy-image-to-server: runs-on: ubuntu-latest needs: build-and-push-image env: - FRONTEND_IMAGE: zerooneitkr/zeroone-frontend:${{ needs.build-and-push-image.outputs.frontend_version }}-${{ needs.build-and-push-image.outputs.frontend_commit }} + FRONTEND_IMAGE: ${{ needs.build-and-push-image.outputs.frontend_image }} RELEASE_ID: ${{ needs.build-and-push-image.outputs.release_id }} DEPLOYED_AT: ${{ needs.build-and-push-image.outputs.deployed_at }} FRONTEND_COMMIT: ${{ needs.build-and-push-image.outputs.frontend_commit }} @@ -150,6 +161,17 @@ jobs: node-version: '20' cache: 'yarn' + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'yarn' + - name: Install cloudflared run: | echo "Downloading cloudflared..." @@ -247,16 +269,15 @@ jobs: - name: Deploy frontend image to production server run: | set -euo pipefail - : "${FRONTEND_IMAGE:?FRONTEND_IMAGE is required}" - sshpass -p '${{ secrets.SSH_PASSWORD }}' ssh ssh.zeroone.it.kr \ - "FRONTEND_IMAGE='$FRONTEND_IMAGE' bash -s" << 'EOF' - set -euo pipefail + sshpass -p '${{ secrets.SSH_PASSWORD }}' ssh ssh.zeroone.it.kr bash << 'EOF' + set -e SUDO_PASSWORD='${{ secrets.SSH_PASSWORD }}' - : "${FRONTEND_IMAGE:?FRONTEND_IMAGE is required}" + FRONTEND_IMAGE='${{ needs.build-and-push-image.outputs.frontend_image }}' - echo "사용하지 않는 이미지, 네트워크 정리 중 (실행 중 컨테이너 보존, 볼륨 제외)..." - echo "$SUDO_PASSWORD" | sudo -S docker image prune -f || true - echo "$SUDO_PASSWORD" | sudo -S docker network prune -f || true + echo "사용하지 않는 컨테이너, 이미지, 네트워크 정리 중 (볼륨제외)..." + echo "$SUDO_PASSWORD" | sudo -S docker stop frontend-prod || true + echo "$SUDO_PASSWORD" | sudo -S docker rm frontend-prod || true + echo "$SUDO_PASSWORD" | sudo -S docker system prune -a -f cd ~/front/study-platform-client-prod || { echo "디렉토리가 없습니다. 생성합니다..." @@ -289,14 +310,14 @@ jobs: restart: unless-stopped COMPOSE - echo "도커 이미지 pull: $FRONTEND_IMAGE" - echo "$SUDO_PASSWORD" | sudo -S docker pull "$FRONTEND_IMAGE" - echo "기존 컨테이너 교체" echo "$SUDO_PASSWORD" | sudo -S docker compose -f docker-compose.prod.yml down || true echo "$SUDO_PASSWORD" | sudo -S docker stop frontend-prod || true echo "$SUDO_PASSWORD" | sudo -S docker rm frontend-prod || true + echo "도커 이미지 pull: $FRONTEND_IMAGE" + echo "$SUDO_PASSWORD" | sudo -S docker pull "$FRONTEND_IMAGE" + echo "도커 컴포즈 재시작" echo "$SUDO_PASSWORD" | sudo -S docker compose -f docker-compose.prod.yml up -d echo "$SUDO_PASSWORD" | sudo -S docker system prune -f || true diff --git a/.gitignore b/.gitignore index c69e93848..ed9534c5d 100644 --- a/.gitignore +++ b/.gitignore @@ -61,4 +61,9 @@ e2e/fixtures/auth.json playwright-report/ test-results/ -.claude/ \ No newline at end of file +.claude/commands +.claude/agents +.claude/skills +.playwright-mcp +.omc +/docs diff --git a/e2e/class/journey-map.spec.ts b/e2e/class/journey-map.spec.ts index eb057ed75..ad5e7c77e 100644 --- a/e2e/class/journey-map.spec.ts +++ b/e2e/class/journey-map.spec.ts @@ -423,7 +423,7 @@ test.describe('안내창 상태 렌더링 @auth', () => { await expect(page.getByText(/NEXT → Lesson 02/)).toBeVisible(); await expect(page.getByText('Chapter3까지 무료 코스!')).not.toBeVisible(); await expect( - page.getByRole('link', { name: '결제하기' }), + page.getByRole('button', { name: '결제하기' }), ).not.toBeVisible(); }); @@ -455,7 +455,7 @@ test.describe('안내창 상태 렌더링 @auth', () => { await expect(page.getByText(/NEXT → Lesson 01/)).toBeVisible(); await expect(page.getByText('Chapter3까지 무료 코스!')).not.toBeVisible(); await expect( - page.getByRole('link', { name: '결제하기' }), + page.getByRole('button', { name: '결제하기' }), ).not.toBeVisible(); }); @@ -473,7 +473,7 @@ test.describe('안내창 상태 렌더링 @auth', () => { await gotoAndWaitForData(page); await expect( - page.getByRole('link', { name: '결제하기' }), + page.getByRole('button', { name: '결제하기' }), ).not.toBeVisible(); }); diff --git a/e2e/class/payment.spec.ts b/e2e/class/payment.spec.ts index b2e020f80..dd91a88dc 100644 --- a/e2e/class/payment.spec.ts +++ b/e2e/class/payment.spec.ts @@ -267,6 +267,7 @@ test.describe('결제 버튼 유효성 검사 @auth', () => { page, }) => { await page.locator('input[name="tosAgreed"]').check({ force: true }); + await page.locator('input[type="radio"][value="CARD"]').check(); await page.getByPlaceholder('이름을 입력해주세요').fill('홍길동'); await page.locator('#buyerEmail').fill('test@example.com'); await page.getByPlaceholder('01012345678').fill('01012345678'); diff --git a/e2e/class/qna.spec.ts b/e2e/class/qna.spec.ts index 85e8c09c0..f670731c8 100644 --- a/e2e/class/qna.spec.ts +++ b/e2e/class/qna.spec.ts @@ -13,6 +13,7 @@ const COURSE_ID = 1; const QNA_ID = 77; const QNA_LIST_PATH = '/class/vibe-intro/home?tab=qna'; const QNA_DETAIL_PATH = `/class/vibe-intro/qa/${QNA_ID}`; +const QNA_WRITE_PATH = '/class/vibe-intro/qna/write'; // ─── Localhost auth cookie injection ───────────────────────────────────────── @@ -235,3 +236,96 @@ test.describe('QnA 상세', () => { expect(page.url()).toContain('tab=qna'); }); }); + +// ─── Chunk 3: QnA 작성 폼 유효성 검사 ──────────────────────────────────────── + +test.describe('QnA 작성 폼 유효성 검사 @auth', () => { + test.beforeEach(async ({ page }) => { + await page.route(/\/courses\//, async (route) => { + const url = route.request().url(); + if (url.includes('/curriculum')) { + await route.fulfill({ + json: { + content: { + courseId: COURSE_ID, + durationDays: 30, + totalChapters: 1, + totalLessons: 1, + chapters: [ + { + chapterId: 10, + order: 1, + chapterNumber: 1, + title: '시작하기', + description: null, + estimatedMinutes: 18, + lessons: [ + { + lessonId: 101, + order: 1, + title: '기초 세팅', + description: null, + isFree: true, + isLocked: false, + estimatedMinutes: 18, + }, + ], + }, + ], + }, + }, + }); + } else if (/\/courses\/vibe-intro$/.test(url)) { + await route.fulfill({ json: makeCourseDetail() }); + } else { + await route.continue(); + } + }); + await page.goto(QNA_WRITE_PATH, { waitUntil: 'load' }); + }); + + test('레슨 미선택 → 제출 버튼 비활성화', async ({ page }) => { + const submitBtn = page + .getByRole('button', { name: /등록|제출|질문/ }) + .first(); + await expect(submitBtn).toBeDisabled({ timeout: 5000 }); + }); + + test('내용 입력 후 제출 → POST /courses/{courseId}/qnas 호출', async ({ + page, + }) => { + await page.route(/\/qnas/, async (route) => { + if (route.request().method() === 'POST') { + await route.fulfill({ json: { content: { qnaId: QNA_ID } } }); + } else { + await route.continue(); + } + }); + + // Open lesson selector and pick first option + const selector = page.locator('select, [role="combobox"]').first(); + await selector.click(); + const firstOption = page.getByRole('option').first(); + if (await firstOption.isVisible({ timeout: 2000 }).catch(() => false)) { + await firstOption.click(); + } + + await page + .locator('textarea, [contenteditable="true"]') + .first() + .fill('레슨을 수강하며 궁금한 점이 생겼습니다.'); + + const [response] = await Promise.all([ + page.waitForResponse( + (r) => r.url().includes('/qnas') && r.request().method() === 'POST', + { timeout: 5000 }, + ), + page + .getByRole('button', { name: /등록|제출|질문/ }) + .first() + .click(), + ]); + + expect(response.status()).toBe(200); + }); +}); diff --git a/e2e/group-study/create.spec.ts b/e2e/group-study/create.spec.ts index aac471068..c0a9a1b7d 100644 --- a/e2e/group-study/create.spec.ts +++ b/e2e/group-study/create.spec.ts @@ -134,7 +134,7 @@ test.describe('멘토스터디 개설 @auth', () => { test('PREMIUM_STUDY 가격 필드 포함 전체 제출', async ({ page }) => { await openPremiumStudyModal(page); - await fillStep1(page); + await fillStep1(page, 'MENTOR_STUDY'); const priceInput = page.getByPlaceholder('10,000'); await priceInput.fill('50000'); diff --git a/public/class/vibe-intro/checklist.svg b/public/class/checklist.svg similarity index 100% rename from public/class/vibe-intro/checklist.svg rename to public/class/checklist.svg diff --git a/public/class/vibe-intro/curriculum/chapter-lock.svg b/public/class/curriculum/chapter-lock.svg similarity index 100% rename from public/class/vibe-intro/curriculum/chapter-lock.svg rename to public/class/curriculum/chapter-lock.svg diff --git a/public/class/vibe-intro/curriculum/chevron-up.svg b/public/class/curriculum/chevron-up.svg similarity index 100% rename from public/class/vibe-intro/curriculum/chevron-up.svg rename to public/class/curriculum/chevron-up.svg diff --git a/public/class/vibe-intro/curriculum/edit-note.svg b/public/class/curriculum/edit-note.svg similarity index 100% rename from public/class/vibe-intro/curriculum/edit-note.svg rename to public/class/curriculum/edit-note.svg diff --git a/public/class/vibe-intro/curriculum/lesson-lock-icon.svg b/public/class/curriculum/lesson-lock-icon.svg similarity index 100% rename from public/class/vibe-intro/curriculum/lesson-lock-icon.svg rename to public/class/curriculum/lesson-lock-icon.svg diff --git a/public/class/vibe-intro/curriculum/line-active.svg b/public/class/curriculum/line-active.svg similarity index 100% rename from public/class/vibe-intro/curriculum/line-active.svg rename to public/class/curriculum/line-active.svg diff --git a/public/class/vibe-intro/curriculum/line-locked.svg b/public/class/curriculum/line-locked.svg similarity index 100% rename from public/class/vibe-intro/curriculum/line-locked.svg rename to public/class/curriculum/line-locked.svg diff --git a/public/class/vibe-intro/curriculum/lock-open.svg b/public/class/curriculum/lock-open.svg similarity index 100% rename from public/class/vibe-intro/curriculum/lock-open.svg rename to public/class/curriculum/lock-open.svg diff --git a/public/class/vibe-intro/curriculum/marker-active.svg b/public/class/curriculum/marker-active.svg similarity index 100% rename from public/class/vibe-intro/curriculum/marker-active.svg rename to public/class/curriculum/marker-active.svg diff --git a/public/class/vibe-intro/curriculum/marker-default.svg b/public/class/curriculum/marker-default.svg similarity index 100% rename from public/class/vibe-intro/curriculum/marker-default.svg rename to public/class/curriculum/marker-default.svg diff --git a/public/class/vibe-intro/curriculum/mode.svg b/public/class/curriculum/mode.svg similarity index 100% rename from public/class/vibe-intro/curriculum/mode.svg rename to public/class/curriculum/mode.svg diff --git a/public/class/detail/audit/node-142-3114.png b/public/class/detail/audit/node-142-3114.png new file mode 100644 index 000000000..f1b935e97 Binary files /dev/null and b/public/class/detail/audit/node-142-3114.png differ diff --git a/public/class/detail/audit/node-210-2643.png b/public/class/detail/audit/node-210-2643.png new file mode 100644 index 000000000..be7447c0a Binary files /dev/null and b/public/class/detail/audit/node-210-2643.png differ diff --git a/public/class/detail/audit/node-225-4596.png b/public/class/detail/audit/node-225-4596.png new file mode 100644 index 000000000..0d4377c47 Binary files /dev/null and b/public/class/detail/audit/node-225-4596.png differ diff --git a/public/class/detail/audit/node-306-2830.png b/public/class/detail/audit/node-306-2830.png new file mode 100644 index 000000000..57ffb1957 Binary files /dev/null and b/public/class/detail/audit/node-306-2830.png differ diff --git a/public/class/detail/audit/node-33-1925.png b/public/class/detail/audit/node-33-1925.png new file mode 100644 index 000000000..0661cd401 Binary files /dev/null and b/public/class/detail/audit/node-33-1925.png differ diff --git a/public/class/detail/audit/node-771-23642.png b/public/class/detail/audit/node-771-23642.png new file mode 100644 index 000000000..36429c103 Binary files /dev/null and b/public/class/detail/audit/node-771-23642.png differ diff --git a/public/class/detail/audit/node-865-33747.png b/public/class/detail/audit/node-865-33747.png new file mode 100644 index 000000000..46447968f Binary files /dev/null and b/public/class/detail/audit/node-865-33747.png differ diff --git a/public/class/detail/audit/node-865-34214.png b/public/class/detail/audit/node-865-34214.png new file mode 100644 index 000000000..9dfec2738 Binary files /dev/null and b/public/class/detail/audit/node-865-34214.png differ diff --git a/public/class/detail/class-detail-figma-ref.png b/public/class/detail/class-detail-figma-ref.png new file mode 100644 index 000000000..2a27a4572 Binary files /dev/null and b/public/class/detail/class-detail-figma-ref.png differ diff --git a/public/class/vibe-intro/lesson-lock.svg b/public/class/lesson-lock.svg similarity index 100% rename from public/class/vibe-intro/lesson-lock.svg rename to public/class/lesson-lock.svg diff --git a/public/class/vibe-intro/payments.json b/public/class/payments.json similarity index 100% rename from public/class/vibe-intro/payments.json rename to public/class/payments.json diff --git a/public/class/vibe-intro/star-disabled.svg b/public/class/star-disabled.svg similarity index 100% rename from public/class/vibe-intro/star-disabled.svg rename to public/class/star-disabled.svg diff --git a/public/class/vibe-intro/star-enabled.svg b/public/class/star-enabled.svg similarity index 100% rename from public/class/vibe-intro/star-enabled.svg rename to public/class/star-enabled.svg diff --git a/public/class/vibe-intro/feed-more-menu.png b/public/class/vibe-intro/feed-more-menu.png new file mode 100644 index 000000000..dc7b67d85 Binary files /dev/null and b/public/class/vibe-intro/feed-more-menu.png differ diff --git a/public/class/vibe-intro/learning-map-modal-ref.png b/public/class/vibe-intro/learning-map-modal-ref.png new file mode 100644 index 000000000..63196191f Binary files /dev/null and b/public/class/vibe-intro/learning-map-modal-ref.png differ diff --git a/src/app/(class-lesson)/class/vibe-intro/lesson/[id]/_components/curriculum-drawer.tsx b/src/app/(class-lesson)/class/[slug]/lesson/[id]/_components/curriculum-drawer.tsx similarity index 95% rename from src/app/(class-lesson)/class/vibe-intro/lesson/[id]/_components/curriculum-drawer.tsx rename to src/app/(class-lesson)/class/[slug]/lesson/[id]/_components/curriculum-drawer.tsx index 412d4948f..d5cad01bb 100644 --- a/src/app/(class-lesson)/class/vibe-intro/lesson/[id]/_components/curriculum-drawer.tsx +++ b/src/app/(class-lesson)/class/[slug]/lesson/[id]/_components/curriculum-drawer.tsx @@ -18,13 +18,14 @@ interface Props { open: boolean; onClose: () => void; courseTitle: string; + courseSlug: string; chapters: CourseDrawerChapterResponse[]; expandedChapters: Set; onToggleChapter: (chapterId: number) => void; isLoading?: boolean; } -const ASSET = '/class/vibe-intro/curriculum'; +const ASSET = '/class/curriculum'; function LessonBadge({ lesson }: { lesson: CourseDrawerLessonResponse }) { const accessible = !lesson.isLocked; @@ -126,8 +127,10 @@ function ChapterHeader({ function ChapterLessons({ lessons, + courseSlug, }: { lessons: CourseDrawerLessonResponse[]; + courseSlug: string; }) { return (
@@ -140,7 +143,7 @@ function ChapterLessons({ return (
  • onToggleChapter(chapter.chapterId)} /> - {expanded && } + {expanded && ( + + )}
  • ); })} diff --git a/src/app/(class-lesson)/class/vibe-intro/lesson/[id]/_components/lesson-builder-feed-card.tsx b/src/app/(class-lesson)/class/[slug]/lesson/[id]/_components/lesson-builder-feed-card.tsx similarity index 98% rename from src/app/(class-lesson)/class/vibe-intro/lesson/[id]/_components/lesson-builder-feed-card.tsx rename to src/app/(class-lesson)/class/[slug]/lesson/[id]/_components/lesson-builder-feed-card.tsx index ce93c5b0c..84a212569 100644 --- a/src/app/(class-lesson)/class/vibe-intro/lesson/[id]/_components/lesson-builder-feed-card.tsx +++ b/src/app/(class-lesson)/class/[slug]/lesson/[id]/_components/lesson-builder-feed-card.tsx @@ -3,17 +3,17 @@ import { ChevronLeft, ChevronRight } from 'lucide-react'; import Image from 'next/image'; import { useState } from 'react'; -import { - AuthorAvatar, - ROLE_LABELS, - RoleBadge, -} from '@/app/(landing)/class/vibe-intro/_components/builder-feed-utils'; import { cn } from '@/components/common/ui/(shadcn)/lib/utils'; import { FeedCommentIcon, FeedHeartIcon, FeedShareIcon, } from '@/components/common/ui/icons/course-icons'; +import { + AuthorAvatar, + ROLE_LABELS, + RoleBadge, +} from '@/components/pages/class/utils/builder-feed-utils'; import type { BuilderFeedPreviewItemResponse } from '@/types/api/course.types'; interface Props { diff --git a/src/app/(class-lesson)/class/vibe-intro/lesson/[id]/_components/lesson-builder-feed-detail-modal.tsx b/src/app/(class-lesson)/class/[slug]/lesson/[id]/_components/lesson-builder-feed-detail-modal.tsx similarity index 98% rename from src/app/(class-lesson)/class/vibe-intro/lesson/[id]/_components/lesson-builder-feed-detail-modal.tsx rename to src/app/(class-lesson)/class/[slug]/lesson/[id]/_components/lesson-builder-feed-detail-modal.tsx index 15a8da1a5..903d3517d 100644 --- a/src/app/(class-lesson)/class/vibe-intro/lesson/[id]/_components/lesson-builder-feed-detail-modal.tsx +++ b/src/app/(class-lesson)/class/[slug]/lesson/[id]/_components/lesson-builder-feed-detail-modal.tsx @@ -2,13 +2,13 @@ import { Heart, MessageCircle, X } from 'lucide-react'; import Image from 'next/image'; +import MarkdownContentCore from '@/components/common/ui/rich-text/markdown-content-core'; import { AuthorAvatar, formatRelativeTime, ROLE_LABELS, RoleBadge, -} from '@/app/(landing)/class/vibe-intro/_components/builder-feed-utils'; -import MarkdownContentCore from '@/components/common/ui/rich-text/markdown-content-core'; +} from '@/components/pages/class/utils/builder-feed-utils'; import { useGetBuilderFeedDetail } from '@/hooks/queries/course/course-api'; interface Props { diff --git a/src/app/(class-lesson)/class/vibe-intro/lesson/[id]/_components/lesson-link-modal.tsx b/src/app/(class-lesson)/class/[slug]/lesson/[id]/_components/lesson-link-modal.tsx similarity index 100% rename from src/app/(class-lesson)/class/vibe-intro/lesson/[id]/_components/lesson-link-modal.tsx rename to src/app/(class-lesson)/class/[slug]/lesson/[id]/_components/lesson-link-modal.tsx diff --git a/src/app/(class-lesson)/class/vibe-intro/lesson/[id]/_components/lesson-qna-card.tsx b/src/app/(class-lesson)/class/[slug]/lesson/[id]/_components/lesson-qna-card.tsx similarity index 100% rename from src/app/(class-lesson)/class/vibe-intro/lesson/[id]/_components/lesson-qna-card.tsx rename to src/app/(class-lesson)/class/[slug]/lesson/[id]/_components/lesson-qna-card.tsx diff --git a/src/app/(class-lesson)/class/vibe-intro/lesson/[id]/_components/lesson-qna-detail-modal.tsx b/src/app/(class-lesson)/class/[slug]/lesson/[id]/_components/lesson-qna-detail-modal.tsx similarity index 100% rename from src/app/(class-lesson)/class/vibe-intro/lesson/[id]/_components/lesson-qna-detail-modal.tsx rename to src/app/(class-lesson)/class/[slug]/lesson/[id]/_components/lesson-qna-detail-modal.tsx diff --git a/src/app/(class-lesson)/class/vibe-intro/lesson/[id]/_components/lesson-qna-submission-modal.tsx b/src/app/(class-lesson)/class/[slug]/lesson/[id]/_components/lesson-qna-submission-modal.tsx similarity index 100% rename from src/app/(class-lesson)/class/vibe-intro/lesson/[id]/_components/lesson-qna-submission-modal.tsx rename to src/app/(class-lesson)/class/[slug]/lesson/[id]/_components/lesson-qna-submission-modal.tsx diff --git a/src/app/(class-lesson)/class/vibe-intro/lesson/[id]/_components/lesson-rating-box.tsx b/src/app/(class-lesson)/class/[slug]/lesson/[id]/_components/lesson-rating-box.tsx similarity index 92% rename from src/app/(class-lesson)/class/vibe-intro/lesson/[id]/_components/lesson-rating-box.tsx rename to src/app/(class-lesson)/class/[slug]/lesson/[id]/_components/lesson-rating-box.tsx index da5855ef5..815ce1cbd 100644 --- a/src/app/(class-lesson)/class/vibe-intro/lesson/[id]/_components/lesson-rating-box.tsx +++ b/src/app/(class-lesson)/class/[slug]/lesson/[id]/_components/lesson-rating-box.tsx @@ -37,8 +37,8 @@ export function RatingBox({ rating, onChange }: Props) { {submitDisabled && ( ; + params: Promise<{ slug: string; id: string }>; }) { - const { id } = use(params); + const { id, slug } = use(params); const lessonId = parseInt(id, 10); const router = useRouter(); const showToast = useToastStore((s) => s.showToast); @@ -159,10 +159,11 @@ export default function LessonPage({ { onSuccess: (data) => { showToast('제출이 완료되었어요!'); + const courseSlugForNav = lesson?.courseSlug ?? slug; if (data.isCourseCompleted) { - router.push('/class/vibe-intro/complete'); + router.push(`/class/${courseSlugForNav}/complete`); } else { - router.push('/class/vibe-intro/home'); + router.push(`/class/${courseSlugForNav}/home`); } }, onError: () => showToast('제출에 실패했어요.', 'error'), @@ -180,6 +181,7 @@ export default function LessonPage({ expandedChapters={expandedChapters} onToggleChapter={toggleChapter} isLoading={isDrawerLoading} + courseSlug={lesson?.courseSlug ?? slug} /> diff --git a/src/app/(landing)/class/vibe-intro/(learning)/_components/feed-tab.tsx b/src/app/(landing)/class/[slug]/(learning)/_components/feed-tab.tsx similarity index 95% rename from src/app/(landing)/class/vibe-intro/(learning)/_components/feed-tab.tsx rename to src/app/(landing)/class/[slug]/(learning)/_components/feed-tab.tsx index 2ae596b03..d02bbc2d5 100644 --- a/src/app/(landing)/class/vibe-intro/(learning)/_components/feed-tab.tsx +++ b/src/app/(landing)/class/[slug]/(learning)/_components/feed-tab.tsx @@ -3,9 +3,10 @@ import { ChevronDown } from 'lucide-react'; import dynamic from 'next/dynamic'; import Link from 'next/link'; +import { useParams } from 'next/navigation'; import { useMemo, useState } from 'react'; import { cn } from '@/components/common/ui/(shadcn)/lib/utils'; -import { PlanSelectionModal } from '@/components/pages/class/vibe-intro/plan-selection-modal'; +import { PlanSelectionModal } from '@/components/pages/class/plan-selection-modal'; import { useAuth } from '@/features/auth/model/use-auth'; import { useGetBuilderFeeds, @@ -41,6 +42,7 @@ const FILTER_API_MAP: Record = { const PAGE_SIZE = 10; export function FeedTab() { + const { slug } = useParams<{ slug: string }>(); const { isAuthenticated } = useAuth(); const [filter, setFilter] = useState('전체'); const [sort, setSort] = useState('최신순'); @@ -50,9 +52,9 @@ export function FeedTab() { const [currentPage, setCurrentPage] = useState(0); const [showPlanModal, setShowPlanModal] = useState(false); - const { data: course } = useGetCourseDetail('vibe-intro'); + const { data: course } = useGetCourseDetail(slug); const courseId = course?.courseId ?? 0; - const { data: curriculum } = useGetCourseCurriculum('vibe-intro'); + const { data: curriculum } = useGetCourseCurriculum(slug); const { data: feedData, isLoading } = useGetBuilderFeeds({ courseId, sort: SORT_API_MAP[sort], @@ -222,7 +224,7 @@ export function FeedTab() { {/* Write CTA */} {isAuthenticated ? ( 피드 올리기 @@ -254,7 +256,7 @@ export function FeedTab() { ) : (
    {feeds.map((feed) => ( - + ))}
    )} @@ -278,6 +280,7 @@ export function FeedTab() { plan={course.plans[0]} earlyBirdEndsAt={course?.earlyBirdEndsAt ?? null} onClose={() => setShowPlanModal(false)} + slug={slug} /> )} diff --git a/src/app/(landing)/class/vibe-intro/(learning)/_components/qna-tab.tsx b/src/app/(landing)/class/[slug]/(learning)/_components/qna-tab.tsx similarity index 96% rename from src/app/(landing)/class/vibe-intro/(learning)/_components/qna-tab.tsx rename to src/app/(landing)/class/[slug]/(learning)/_components/qna-tab.tsx index 33ab85bff..4fced08eb 100644 --- a/src/app/(landing)/class/vibe-intro/(learning)/_components/qna-tab.tsx +++ b/src/app/(landing)/class/[slug]/(learning)/_components/qna-tab.tsx @@ -1,7 +1,7 @@ 'use client'; import { ChevronDown, Search } from 'lucide-react'; -import { useRouter } from 'next/navigation'; +import { useParams, useRouter } from 'next/navigation'; import { useEffect, useRef, useState } from 'react'; import { cn } from '@/components/common/ui/(shadcn)/lib/utils'; import { @@ -39,6 +39,7 @@ function getEmptyMessage(filter: string) { } export function QnaTab() { + const { slug } = useParams<{ slug: string }>(); const router = useRouter(); const [filter, setFilter] = useState(''); @@ -63,7 +64,7 @@ export function QnaTab() { return () => document.removeEventListener('mousedown', handleClick); }, []); - const { data: courseData } = useGetCourseDetail('vibe-intro'); + const { data: courseData } = useGetCourseDetail(slug); const courseId = courseData?.courseId ?? 0; const { data, isLoading } = useGetCourseQnas({ courseId, @@ -149,7 +150,7 @@ export function QnaTab() { {/* 질문하기 button */} 확인 @@ -207,7 +203,7 @@ function FeedWriteContent() {
    @@ -364,7 +360,7 @@ function FeedWriteContent() { 확인 @@ -134,7 +135,7 @@ export default function QnaWritePage() {
    diff --git a/src/app/(landing)/class/vibe-intro/_components/builder-feed-utils.tsx b/src/app/(landing)/class/[slug]/_components/builder-feed-utils.tsx similarity index 100% rename from src/app/(landing)/class/vibe-intro/_components/builder-feed-utils.tsx rename to src/app/(landing)/class/[slug]/_components/builder-feed-utils.tsx diff --git a/src/app/(landing)/class/vibe-intro/complete/page.tsx b/src/app/(landing)/class/[slug]/complete/page.tsx similarity index 97% rename from src/app/(landing)/class/vibe-intro/complete/page.tsx rename to src/app/(landing)/class/[slug]/complete/page.tsx index 5d8a5f898..8aa75ec0f 100644 --- a/src/app/(landing)/class/vibe-intro/complete/page.tsx +++ b/src/app/(landing)/class/[slug]/complete/page.tsx @@ -4,6 +4,7 @@ import confetti from 'canvas-confetti'; import { Link as LinkIcon } from 'lucide-react'; import Image from 'next/image'; import Link from 'next/link'; +import { useParams } from 'next/navigation'; import { useEffect, useState } from 'react'; import { cn } from '@/components/common/ui/(shadcn)/lib/utils'; import FloatingClassActionButtons from '@/components/common/ui/floating-class-action-buttons'; @@ -24,6 +25,7 @@ async function handleShareLink(showToast: (msg: string) => void) { } export default function CourseCompletePage() { + const { slug } = useParams<{ slug: string }>(); const [feedback, setFeedback] = useState(''); const showToast = useToastStore((s) => s.showToast); @@ -49,7 +51,7 @@ export default function CourseCompletePage() { frame().catch(() => {}); }, []); - const { data: course } = useGetCourseDetail('vibe-intro'); + const { data: course } = useGetCourseDetail(slug); const courseId = course?.courseId ?? 0; const { data: recap } = useGetCourseCompletionRecap(courseId); const submitNextPlan = useSubmitNextPlan(); @@ -186,7 +188,7 @@ export default function CourseCompletePage() { {/* Final CTAs */}
    { if (feedback.trim()) handleNextPlanSubmit(); }} diff --git a/src/app/(landing)/class/[id]/page.tsx b/src/app/(landing)/class/[slug]/page.tsx similarity index 98% rename from src/app/(landing)/class/[id]/page.tsx rename to src/app/(landing)/class/[slug]/page.tsx index 6e22429dc..11420b7cb 100644 --- a/src/app/(landing)/class/[id]/page.tsx +++ b/src/app/(landing)/class/[slug]/page.tsx @@ -34,9 +34,9 @@ import { useToastStore } from '@/stores/use-toast-store'; export default function ClassDetailPage({ params, }: { - params: Promise<{ id: string }>; + params: Promise<{ slug: string }>; }) { - const { id: slug } = use(params); + const { slug } = use(params); const router = useRouter(); const [activeTab, setActiveTab] = useState('roadmap'); const [expandedChapters, setExpandedChapters] = useState>( @@ -67,8 +67,7 @@ export default function ClassDetailPage({ }); const registerGiftEmailMutation = useRegisterGiftEmail(); - const learningHomeHref = - slug === 'vibe-intro' ? '/class/vibe-intro/home' : `/class/${slug}`; + const learningHomeHref = `/class/${slug}/home`; const chaptersForRoadmap = useMemo(() => { if (curriculum?.chapters && curriculum.chapters.length > 0) { diff --git a/src/app/(landing)/class/vibe-intro/(learning)/feed/[id]/edit/page.tsx b/src/app/(landing)/class/vibe-intro/(learning)/feed/[id]/edit/page.tsx deleted file mode 100644 index 8444ea441..000000000 --- a/src/app/(landing)/class/vibe-intro/(learning)/feed/[id]/edit/page.tsx +++ /dev/null @@ -1,11 +0,0 @@ -import { redirect } from 'next/navigation'; -import { use } from 'react'; - -export default function FeedEditPage({ - params, -}: { - params: Promise<{ id: string }>; -}) { - const { id } = use(params); - redirect(`/class/vibe-intro/feed/write?feedId=${id}`); -} diff --git a/src/app/(landing)/class/vibe-intro/(learning)/feed/page.tsx b/src/app/(landing)/class/vibe-intro/(learning)/feed/page.tsx deleted file mode 100644 index 5afa4c783..000000000 --- a/src/app/(landing)/class/vibe-intro/(learning)/feed/page.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import { redirect } from 'next/navigation'; - -export default function FeedIndexPage() { - redirect('/class/vibe-intro/home?tab=feed'); -} diff --git a/src/app/(landing)/class/vibe-intro/(learning)/qa/page.tsx b/src/app/(landing)/class/vibe-intro/(learning)/qa/page.tsx deleted file mode 100644 index 8d1f47d0f..000000000 --- a/src/app/(landing)/class/vibe-intro/(learning)/qa/page.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import { redirect } from 'next/navigation'; - -export default function QnaIndexPage() { - redirect('/class/vibe-intro/home?tab=qna'); -} diff --git a/src/app/(service)/payment/fail/page.tsx b/src/app/(service)/payment/fail/page.tsx index 3c5e8a826..ce094e9e4 100644 --- a/src/app/(service)/payment/fail/page.tsx +++ b/src/app/(service)/payment/fail/page.tsx @@ -129,7 +129,7 @@ function PaymentFailContent() { const handleConfirm = () => { if (type === 'course') { - router.push('/class/vibe-intro/home'); + router.push('/class'); return; } diff --git a/src/app/(service)/payment/success/page.tsx b/src/app/(service)/payment/success/page.tsx index 278b4b6c4..f90d473fb 100644 --- a/src/app/(service)/payment/success/page.tsx +++ b/src/app/(service)/payment/success/page.tsx @@ -5,9 +5,7 @@ import { useSearchParams, useRouter } from 'next/navigation'; import { Suspense, useEffect, useState } from 'react'; import { StudyPaymentDetailResponse, VirtualAccountInfo } from '@/api/openapi'; import Button from '@/components/common/ui/button'; -import { useConfirmCourseTossPayment } from '@/hooks/queries/course/course-api'; import { useConfirmTossPayment } from '@/hooks/queries/payment/payment-user-api'; -import type { CoursePaymentConfirmResponse } from '@/types/api/course.types'; function PaymentSuccessContent() { const searchParams = useSearchParams(); @@ -18,11 +16,8 @@ function PaymentSuccessContent() { >('loading'); const [paymentData, setPaymentData] = useState(null); - const [coursePaymentData, setCoursePaymentData] = - useState(null); const { mutateAsync } = useConfirmTossPayment(); - const { mutateAsync: confirmCoursePayment } = useConfirmCourseTossPayment(); const [errorMessage, setErrorMessage] = useState(null); @@ -34,11 +29,8 @@ function PaymentSuccessContent() { const orderId = searchParams.get('orderId'); const amount = searchParams.get('amount'); const method = searchParams.get('method'); - const type = searchParams.get('type'); - const courseId = Number(searchParams.get('courseId')); const isVirtualAccount = method === 'VIRTUAL_ACCOUNT'; - const isCoursePayment = type === 'course'; useEffect(() => { if (!paymentKey || !orderId || !amount) { @@ -48,13 +40,6 @@ function PaymentSuccessContent() { return; } - if (isCoursePayment && !Number.isFinite(courseId)) { - setStatus('error'); - setErrorMessage('코스 결제 정보가 올바르지 않습니다.'); - - return; - } - let isMounted = true; const verifyPaymentWithBackend = async () => { @@ -67,17 +52,9 @@ function PaymentSuccessContent() { orderId, amount: Number(amount), }; - const result = isCoursePayment - ? await confirmCoursePayment({ courseId, request }) - : await mutateAsync(request); + const result = await mutateAsync(request); if (isMounted) { - if (isCoursePayment) { - setCoursePaymentData(result as CoursePaymentConfirmResponse); - setStatus('success'); - return; - } - const studyPaymentResult = result as StudyPaymentDetailResponse; setPaymentData(studyPaymentResult ?? null); @@ -103,17 +80,7 @@ function PaymentSuccessContent() { return () => { isMounted = false; }; - }, [ - paymentKey, - orderId, - amount, - mutateAsync, - paymentId, - isVirtualAccount, - isCoursePayment, - confirmCoursePayment, - courseId, - ]); + }, [paymentKey, orderId, amount, mutateAsync, paymentId, isVirtualAccount]); if (status === 'loading') { return ( @@ -205,11 +172,7 @@ function PaymentSuccessContent() {
    @@ -217,20 +180,18 @@ function PaymentSuccessContent() {
    @@ -243,13 +204,9 @@ function PaymentSuccessContent() { className="w-full" color="primary" size="large" - onClick={() => - router.push( - isCoursePayment ? '/class/vibe-intro/home' : '/my-study', - ) - } + onClick={() => router.push('/my-study')} > - {isCoursePayment ? '코스 학습으로 이동' : '마이스터디로 이동'} + 마이스터디로 이동
    -
    diff --git a/src/components/common/modals/builder-profile-modal.tsx b/src/components/common/modals/builder-profile-modal.tsx index 83911ca34..2d6c16c0d 100644 --- a/src/components/common/modals/builder-profile-modal.tsx +++ b/src/components/common/modals/builder-profile-modal.tsx @@ -2,8 +2,8 @@ import { X } from 'lucide-react'; import Image from 'next/image'; -import { RoleBadge } from '@/app/(landing)/class/vibe-intro/_components/builder-feed-utils'; import { Modal } from '@/components/common/ui/modal'; +import { RoleBadge } from '@/components/pages/class/utils/builder-feed-utils'; import { useUserProfileQuery } from '@/hooks/queries/user/use-user-profile-query'; interface BuilderProfileModalProps { diff --git a/src/components/pages/class/vibe-intro/chapter-header.tsx b/src/components/pages/class/chapter-header.tsx similarity index 100% rename from src/components/pages/class/vibe-intro/chapter-header.tsx rename to src/components/pages/class/chapter-header.tsx diff --git a/src/components/pages/class/vibe-intro/home-constants.ts b/src/components/pages/class/home-constants.ts similarity index 99% rename from src/components/pages/class/vibe-intro/home-constants.ts rename to src/components/pages/class/home-constants.ts index d13aa7a37..f63addd64 100644 --- a/src/components/pages/class/vibe-intro/home-constants.ts +++ b/src/components/pages/class/home-constants.ts @@ -4,8 +4,6 @@ import type { LessonProgressStatus, } from '@/types/api/course.types'; -export const COURSE_SLUG = 'vibe-intro'; - export const FALLBACK_CHAPTERS: CourseCurriculumChapterResponse[] = [ { chapterId: -1, diff --git a/src/components/pages/class/vibe-intro/lesson-preview-modal.tsx b/src/components/pages/class/lesson-preview-modal.tsx similarity index 100% rename from src/components/pages/class/vibe-intro/lesson-preview-modal.tsx rename to src/components/pages/class/lesson-preview-modal.tsx diff --git a/src/components/pages/class/vibe-intro/lesson-stamp.tsx b/src/components/pages/class/lesson-stamp.tsx similarity index 96% rename from src/components/pages/class/vibe-intro/lesson-stamp.tsx rename to src/components/pages/class/lesson-stamp.tsx index 033098e9d..0969b8f81 100644 --- a/src/components/pages/class/vibe-intro/lesson-stamp.tsx +++ b/src/components/pages/class/lesson-stamp.tsx @@ -17,6 +17,7 @@ interface LessonStampProps { onSelect: (lesson: LessonDisplayInfo) => void; shouldBlink?: boolean; learnerCount: number; + slug: string; } export function LessonStamp({ @@ -25,6 +26,7 @@ export function LessonStamp({ onSelect, shouldBlink = false, learnerCount, + slug, }: LessonStampProps) { const [showTooltip, setShowTooltip] = useState(false); const isCompleted = lesson.status === 'COMPLETED'; @@ -40,8 +42,8 @@ export function LessonStamp({ void; } -interface VibeIntroCheckoutFormProps { +interface CourseCheckoutFormProps { + slug: string; plan: CoursePlanResponse; paymentData: CoursePaymentPrepareResponse; planCode: CoursePlanCode; onChangePlan: () => void; } -export function VibeIntroCheckoutForm({ +export function CourseCheckoutForm({ + slug, plan, paymentData, planCode, onChangePlan, -}: VibeIntroCheckoutFormProps) { +}: CourseCheckoutFormProps) { const { memberId, memberName, tel } = useUserStore(); const showToast = useToastStore((s) => s.showToast); @@ -145,9 +147,9 @@ export function VibeIntroCheckoutForm({ orderId: paymentData.tossOrderId, orderName: paymentData.orderName, successUrl: - `${window.location.origin}/class/vibe-intro/payment/success` + + `${window.location.origin}/class/${slug}/payment/success` + `?paymentId=${paymentData.paymentId}&method=${paymentMethod}`, - failUrl: `${window.location.origin}/class/vibe-intro/payment?planCode=${planCode}`, + failUrl: `${window.location.origin}/class/${slug}/payment?planCode=${planCode}`, customerName: values.buyerName, customerMobilePhone: values.buyerPhone, }; @@ -220,7 +222,7 @@ export function VibeIntroCheckoutForm({ navigationGuardRef.current.beforeUnload, ); } - window.location.href = '/class/vibe-intro/home'; + window.location.href = `/class/${slug}/home`; }; const canPay = !isLoading; @@ -231,7 +233,11 @@ export function VibeIntroCheckoutForm({ onSubmit={handlePay} className="mx-auto flex w-full max-w-9175 flex-col gap-300 px-600 pb-1000 pt-500" > - + diff --git a/src/components/pages/class/vibe-intro/payment/course-summary-section.tsx b/src/components/pages/class/payment/course-summary-section.tsx similarity index 96% rename from src/components/pages/class/vibe-intro/payment/course-summary-section.tsx rename to src/components/pages/class/payment/course-summary-section.tsx index c81d59ff4..c6d3c1448 100644 --- a/src/components/pages/class/vibe-intro/payment/course-summary-section.tsx +++ b/src/components/pages/class/payment/course-summary-section.tsx @@ -4,11 +4,13 @@ import type { CoursePlanResponse } from '@/types/api/course.types'; interface CourseSummarySectionProps { plan: CoursePlanResponse; onChangePlan: () => void; + slug: string; } export function CourseSummarySection({ plan, onChangePlan, + slug, }: CourseSummarySectionProps) { return (
    @@ -17,7 +19,7 @@ export function CourseSummarySection({
    바이브 코딩 입문자 코스 void; isCanceling?: boolean; } -// Lottie file required at: public/class/vibe-intro/payments.json -export function VibeIntroPaymentPending({ +export function CoursePaymentPending({ virtualAccount, onCancel, isCanceling = false, -}: VibeIntroPaymentPendingProps) { +}: CoursePaymentPendingProps) { const showToast = useToastStore((s) => s.showToast); const handleCopyAccount = async () => { @@ -46,7 +45,7 @@ export function VibeIntroPaymentPending({ return (
    diff --git a/src/components/pages/class/vibe-intro/payment/payment-success.tsx b/src/components/pages/class/payment/payment-success.tsx similarity index 93% rename from src/components/pages/class/vibe-intro/payment/payment-success.tsx rename to src/components/pages/class/payment/payment-success.tsx index 8d1b14319..e7dda8c6a 100644 --- a/src/components/pages/class/vibe-intro/payment/payment-success.tsx +++ b/src/components/pages/class/payment/payment-success.tsx @@ -13,14 +13,15 @@ const DotLottieReact = dynamic( import { cn } from '@/components/common/ui/(shadcn)/lib/utils'; import type { CoursePaymentConfirmResponse } from '@/types/api/course.types'; -interface VibeIntroPaymentSuccessProps { +interface CoursePaymentSuccessProps { paymentConfirm: CoursePaymentConfirmResponse; + slug: string; } -// Lottie file required at: public/class/vibe-intro/payments.json -export function VibeIntroPaymentSuccess({ +export function CoursePaymentSuccess({ paymentConfirm, -}: VibeIntroPaymentSuccessProps) { + slug, +}: CoursePaymentSuccessProps) { const PAYMENT_METHOD_LABELS = { CARD: '신용카드', VIRTUAL_ACCOUNT: '무통장 입금', @@ -31,7 +32,7 @@ export function VibeIntroPaymentSuccess({ return (
    @@ -91,7 +92,7 @@ export function VibeIntroPaymentSuccess({ 마이클래스 내 학습 여정 맵으로 가기 diff --git a/src/components/pages/class/vibe-intro/payment/tos-modal.tsx b/src/components/pages/class/payment/tos-modal.tsx similarity index 100% rename from src/components/pages/class/vibe-intro/payment/tos-modal.tsx rename to src/components/pages/class/payment/tos-modal.tsx diff --git a/src/components/pages/class/vibe-intro/payment/tos-section.tsx b/src/components/pages/class/payment/tos-section.tsx similarity index 100% rename from src/components/pages/class/vibe-intro/payment/tos-section.tsx rename to src/components/pages/class/payment/tos-section.tsx diff --git a/src/components/pages/class/vibe-intro/plan-selection-modal.tsx b/src/components/pages/class/plan-selection-modal.tsx similarity index 97% rename from src/components/pages/class/vibe-intro/plan-selection-modal.tsx rename to src/components/pages/class/plan-selection-modal.tsx index 0857161c7..c6b890028 100644 --- a/src/components/pages/class/vibe-intro/plan-selection-modal.tsx +++ b/src/components/pages/class/plan-selection-modal.tsx @@ -7,10 +7,12 @@ export function PlanSelectionModal({ plan, earlyBirdEndsAt, onClose, + slug, }: { plan: CoursePlanResponse; earlyBirdEndsAt: string | null; onClose: () => void; + slug: string; }) { const isEarlyBird = !!earlyBirdEndsAt && new Date(earlyBirdEndsAt) > new Date(); @@ -96,7 +98,7 @@ export function PlanSelectionModal({
    diff --git a/src/components/pages/class/vibe-intro/roadmap-tab.tsx b/src/components/pages/class/roadmap-tab.tsx similarity index 97% rename from src/components/pages/class/vibe-intro/roadmap-tab.tsx rename to src/components/pages/class/roadmap-tab.tsx index 1d38babc9..4b7fd12e0 100644 --- a/src/components/pages/class/vibe-intro/roadmap-tab.tsx +++ b/src/components/pages/class/roadmap-tab.tsx @@ -23,7 +23,6 @@ import { useToastStore } from '@/stores/use-toast-store'; import type { CourseCurriculumChapterResponse } from '@/types/api/course.types'; import { ChapterHeader } from './chapter-header'; import { - COURSE_SLUG, FALLBACK_CHAPTERS, buildLessonMap, mergeLessons, @@ -37,12 +36,12 @@ const LoginModal = dynamic( () => import('@/components/auth/modals/login-modal'), ); -export function RoadmapTab() { +export function RoadmapTab({ slug }: { slug: string }) { const { isAuthenticated } = useAuth(); - const { data: course } = useGetCourseDetail(COURSE_SLUG); + const { data: course } = useGetCourseDetail(slug); const courseId = course?.courseId ?? 0; - const { data: curriculum, isLoading } = useGetCourseCurriculum(COURSE_SLUG); + const { data: curriculum, isLoading } = useGetCourseCurriculum(slug); const { data: journeyMap } = useGetCourseJourneyMap(courseId); const { data: progress } = useGetCourseProgress(courseId); const router = useRouter(); @@ -125,7 +124,7 @@ export function RoadmapTab() {
    진도 표시
    ))} @@ -328,7 +328,7 @@ export function RoadmapTab() { index < visibleChapters.length - 1 && (
    {card} @@ -537,7 +537,7 @@ export function RoadmapTab() { learnerCount={course?.learnerCount ?? 0} onStart={() => { router.push( - `/class/vibe-intro/lesson/${selectedLesson.lesson.lessonId}`, + `/class/${slug}/lesson/${selectedLesson.lesson.lessonId}`, ); setSelectedLesson(null); }} @@ -566,6 +566,7 @@ export function RoadmapTab() { plan={course.plans[0]} earlyBirdEndsAt={course?.earlyBirdEndsAt ?? null} onClose={() => setShowPlanModal(false)} + slug={slug} /> )} diff --git a/src/components/pages/class/utils/builder-feed-utils.tsx b/src/components/pages/class/utils/builder-feed-utils.tsx new file mode 100644 index 000000000..7d475162e --- /dev/null +++ b/src/components/pages/class/utils/builder-feed-utils.tsx @@ -0,0 +1,60 @@ +import { cn } from '@/components/common/ui/(shadcn)/lib/utils'; + +export const ROLE_LABELS: Record = { + BUILDER: '빌더', + MANAGER: '매니저', + ADMIN: '운영자', +}; + +const ROLE_BADGE_CONFIG: Record = { + BUILDER: { label: 'B', bg: 'bg-background-accent-purple-strong' }, + MANAGER: { label: 'M', bg: 'bg-background-brand-default' }, +}; + +export function formatRelativeTime(dateStr: string): string { + const minutes = Math.floor( + (Date.now() - new Date(dateStr).getTime()) / 60000, + ); + if (minutes < 1) return '방금 전'; + if (minutes < 60) return `${minutes}분 전`; + const hours = Math.floor(minutes / 60); + if (hours < 24) return `${hours}시간 전`; + return `${Math.floor(hours / 24)}일 전`; +} + +export function AuthorAvatar({ + nickname, + className, +}: { + nickname: string; + className?: string; +}) { + return ( +
    + + {nickname.charAt(0)} + +
    + ); +} + +export function RoleBadge({ role }: { role: string }) { + const config = ROLE_BADGE_CONFIG[role]; + if (!config) return null; + return ( + + {config.label} + + ); +}