Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions e2e/class/journey-map.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -484,6 +484,7 @@ test.describe('안내창 상태 렌더링 @auth', () => {
viewerStatus: 'LOGIN_ONLY',
canPurchase: true,
freeLessonCount: 0,
isFreeEnrolled: false,
}),
progress: makeProgress(0),
});
Expand Down
31 changes: 21 additions & 10 deletions e2e/group-study/create.spec.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
import { test, expect, type BrowserContext } from '@playwright/test';
import { test, expect } from '@playwright/test';

// 아래 imports는 그룹/멘토스터디 개설 테스트 재활성화 시 필요
/*
import { type BrowserContext } from '@playwright/test';
import { existsSync, readFileSync } from 'fs';
import {
openCreateModal,
Expand All @@ -7,6 +11,7 @@ import {
fillStep2,
fillStep3,
assertCreationSuccess,
mockThumbnailUpload,
API_BASE,
} from '../support/study-helpers';

Expand Down Expand Up @@ -48,23 +53,26 @@ async function injectAuthCookies(
}
}
}
*/

// ── 그룹스터디 개설 ──────────────────────────────────────────────
// ── 그룹스터디 개설 (임시 비활성화) ──────────────────────────────────────────────
/*
test.describe('그룹스터디 개설 @auth', () => {
let createdStudyId: number | null = null;

test.beforeEach(async ({ context, baseURL }) => {
test.beforeEach(async ({ page, context, baseURL }) => {
await injectAuthCookies(context, baseURL);
await mockThumbnailUpload(page);
});

test.afterEach(async ({ request }) => {
const idToDelete = createdStudyId; // ① 로컬에 캡처
createdStudyId = null; // ② 동기적으로 즉시 초기화 (require-atomic-updates 해결)
const idToDelete = createdStudyId;
createdStudyId = null;
if (idToDelete !== null) {
try {
await request.delete(`${API_BASE}/api/v1/group-studies/${idToDelete}`);
} catch {
// best-effort: 이미 삭제됐거나 권한 없는 경우 무시
// best-effort
}
}
});
Expand All @@ -80,7 +88,6 @@ test.describe('그룹스터디 개설 @auth', () => {

await fillStep3(page);

// 제출 클릭과 동시에 생성 API 응답을 캡처해 groupStudyId 확보
const [response] = await Promise.all([
page.waitForResponse(
(res) =>
Expand Down Expand Up @@ -111,13 +118,16 @@ test.describe('그룹스터디 개설 @auth', () => {
await expect(page.locator('[role="dialog"]')).toBeVisible();
});
});
*/

// ── 멘토스터디 개설 ──────────────────────────────────────────────
// ── 멘토스터디 개설 (임시 비활성화) ──────────────────────────────────────────────
/*
test.describe('멘토스터디 개설 @auth', () => {
let createdStudyId: number | null = null;

test.beforeEach(async ({ context, baseURL }) => {
test.beforeEach(async ({ page, context, baseURL }) => {
await injectAuthCookies(context, baseURL);
await mockThumbnailUpload(page);
});

test.afterEach(async ({ request }) => {
Expand All @@ -127,7 +137,7 @@ test.describe('멘토스터디 개설 @auth', () => {
try {
await request.delete(`${API_BASE}/api/v1/group-studies/${idToDelete}`);
} catch {
// best-effort: 이미 삭제됐거나 권한 없는 경우 무시
// best-effort
}
}
});
Expand Down Expand Up @@ -171,6 +181,7 @@ test.describe('멘토스터디 개설 @auth', () => {
);
});
});
*/

// ── 비로그인 UI ────────────────────────────────────────────────
test.describe('비로그인 UI', () => {
Expand Down
16 changes: 13 additions & 3 deletions e2e/support/study-helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,15 +70,25 @@ export async function fillStep1(page: Page, type: StudyType = 'PROJECT') {
await page.locator('input[type="date"]').nth(1).fill(addDays(42));
}

// Minimal valid 1×1 PNG — sets thumbnailExtension without requiring a real image host
const PNG_1x1 = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJ' +
'AAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==',
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==',
'base64',
);

export async function mockThumbnailUpload(page: Page) {
await page.route('**amazonaws.com/**', async (route) => {
if (route.request().method() === 'PUT') {
await route.fulfill({ status: 200 });
} else {
await route.continue();
}
});
}

export async function fillStep2(page: Page, title: string) {
await page.setInputFiles('input[type="file"]', {
name: 'test-image.png',
name: 'thumb.png',
mimeType: 'image/png',
buffer: PNG_1x1,
});
Expand Down
Binary file added public/onboarding/mascot.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
26 changes: 25 additions & 1 deletion src/app/(landing)/class/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,15 @@
import { ChevronDown, X } from 'lucide-react';
import Image from 'next/image';
import Link from 'next/link';
import { useState } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import { Suspense, useEffect, useState } from 'react';
import { OnboardingModal } from '@/components/auth/modals/onboarding-modal/onboarding-modal';
import { cn } from '@/components/common/ui/(shadcn)/lib/utils';
import {
useCreateOpenAlertSubscription,
useGetCourseList,
} from '@/hooks/queries/course/course-api';
import { useOnboardingStore } from '@/stores/use-onboarding-store';
import { useToastStore } from '@/stores/use-toast-store';
import type { CourseSummaryResponse } from '@/types/api/course.types';

Expand Down Expand Up @@ -528,6 +531,23 @@ function CourseCard({
);
}

function OnboardingTrigger(): null {
const searchParams = useSearchParams();
const router = useRouter();
const openOnboarding = useOnboardingStore((s) => s.open);

useEffect(() => {
if (searchParams.get('onboarding') === 'true') {
openOnboarding();
const params = new URLSearchParams(searchParams.toString());
params.delete('onboarding');
router.replace(params.size ? `/class?${params}` : '/class');
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}, [searchParams, openOnboarding, router]);

return null;
}

export default function ClassPage() {
const [sortOpen, setSortOpen] = useState(false);
const [sort, setSort] = useState<SortOption>('최신순');
Expand All @@ -538,6 +558,10 @@ export default function ClassPage() {

return (
<div className="w-full">
<OnboardingModal />
<Suspense>
<OnboardingTrigger />
</Suspense>
<NotifyModal
open={notifyModalOpen}
courseId={notifyCourseId}
Expand Down
2 changes: 2 additions & 0 deletions src/app/global.css
Original file line number Diff line number Diff line change
Expand Up @@ -429,6 +429,7 @@ https://velog.io/@oneook/tailwindcss-4.0-%EB%AC%B4%EC%97%87%EC%9D%B4-%EB%8B%AC%E
--spacing-1375: 110px;
--spacing-1400: 112px;
--spacing-1500: 120px;
--spacing-1600: 128px;
--spacing-1625: 130px;
--spacing-1650: 132px;
--spacing-1750: 140px;
Expand All @@ -442,6 +443,7 @@ https://velog.io/@oneook/tailwindcss-4.0-%EB%AC%B4%EC%97%87%EC%9D%B4-%EB%8B%AC%E
--spacing-6750: 540px;
--spacing-9250: 740px;
--spacing-7125: 570px;
--spacing-7500: 600px;
--spacing-10500: 840px;
--spacing-3400: 272px;
--spacing-3500: 280px;
Expand Down
164 changes: 164 additions & 0 deletions src/components/auth/modals/onboarding-modal/onboarding-modal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
'use client';

import { X } from 'lucide-react';
import { useEffect, useState } from 'react';
import { createPortal } from 'react-dom';
import { cn } from '@/components/common/ui/(shadcn)/lib/utils';
import { useOnboardingStore } from '@/stores/use-onboarding-store';
import { Step1Nickname } from './steps/step-1-nickname';
import { Step2Job } from './steps/step-2-job';
import { Step3Goals } from './steps/step-3-goals';
import { Step4Completion } from './steps/step-4-completion';

type Step = 1 | 2 | 3 | 4;

interface OnboardingData {
nickname: string;
profileImageUrl?: string;
profileImageFile?: File;
career?: string;
job?: string;
goals: string[];
goalEtcText?: string;
termsAgreed: boolean;
privacyAgreed: boolean;
marketingAgreed: boolean;
}

export function OnboardingModal() {
const { isOpen, close } = useOnboardingStore();
const [mounted, setMounted] = useState(false);
const [currentStep, setCurrentStep] = useState<Step>(1);
const [isSubmitting, setIsSubmitting] = useState(false);
const [data, setData] = useState<OnboardingData>({
nickname: '',
goals: [],
termsAgreed: false,
privacyAgreed: false,
marketingAgreed: false,
});

useEffect(() => {
setMounted(true);
return () => setMounted(false);
}, []);

useEffect(() => {
if (isOpen) {
setCurrentStep(1);
setData({
nickname: '',
goals: [],
termsAgreed: false,
privacyAgreed: false,
marketingAgreed: false,
});
}
}, [isOpen]);

const updateData = (field: keyof OnboardingData, value: unknown) => {
setData((prev) => ({ ...prev, [field]: value }));
};

const handleNext = () => {
if (currentStep < 4) setCurrentStep((prev) => (prev + 1) as Step);
};

const handleBack = () => {
if (currentStep > 1) setCurrentStep((prev) => (prev - 1) as Step);
};

if (!mounted || !isOpen) return null;

const renderStep = () => {
switch (currentStep) {
case 1:
return (
<Step1Nickname
data={data}
updateData={updateData}
onNext={handleNext}
/>
);
case 2:
return (
<Step2Job data={data} updateData={updateData} onNext={handleNext} />
);
case 3:
return (
<Step3Goals data={data} updateData={updateData} onNext={handleNext} />
);
case 4:
return (
<Step4Completion data={data} onSubmittingChange={setIsSubmitting} />
);
}
};

return createPortal(
<div className="fixed inset-0 z-50 flex items-center justify-center px-300">
{/* Backdrop */}
<div
className="absolute inset-0 bg-black/40"
onClick={isSubmitting ? undefined : close}
aria-hidden="true"
/>

{/* Modal panel */}
<div className="relative flex max-h-[90vh] w-full max-w-7500 flex-col overflow-hidden rounded-200 bg-white">
{/* Header */}
<div className="flex items-center justify-between px-500 pt-400 pb-300">
{/* Back button */}
{currentStep > 1 ? (
<button
type="button"
onClick={handleBack}
disabled={isSubmitting}
className="rounded-100 p-100 text-gray-500 transition-colors hover:bg-gray-100"
Comment thread
coderabbitai[bot] marked this conversation as resolved.
aria-label="이전"
>
<X className="h-300 w-300 rotate-[135deg]" />
</button>
) : (
<div className="size-500" />
)}

{/* Step indicator */}
<div className="flex items-center gap-100">
{([1, 2, 3, 4] as Step[]).map((step) => (
<div
key={step}
className={cn(
'rounded-full bg-gray-300 transition-all duration-300',
step === currentStep ? 'h-125 w-350 bg-rose-500' : 'size-125',
)}
/>
))}
</div>

{/* Close button */}
<button
type="button"
onClick={close}
disabled={isSubmitting}
className="rounded-100 p-100 text-gray-500 transition-colors hover:bg-gray-100"
aria-label="닫기"
>
<X className="h-300 w-300" />
</button>
</div>

{/* Content */}
<div className="overflow-y-auto px-500 pb-500">
<div
key={currentStep}
className="animate-in slide-in-from-right-4 fade-in fill-mode-forwards flex flex-col duration-300"
>
{renderStep()}
</div>
</div>
</div>
</div>,
document.body,
);
}
Loading
Loading