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
11 changes: 11 additions & 0 deletions .claude/launch.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"version": "0.0.1",
"configurations": [
{
"name": "next-dev",
"runtimeExecutable": "node",
"runtimeArgs": ["D:/a/문서/workspace/lets-intern-client/node_modules/next/dist/bin/next", "dev"],
"port": 3000
}
]
}
11 changes: 10 additions & 1 deletion .claude/settings.local.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,16 @@
"Bash(npx tsc:*)",
"Bash(xargs:*)",
"Bash(git mv:*)",
"Bash(npx vitest:*)"
"Bash(npx vitest:*)",
"mcp__Claude_Preview__preview_start",
"Bash(curl:*)",
"WebFetch(domain:www.figma.com)",
"WebFetch(domain:api.figma.com)",
"Bash(python3:*)",
"Bash(python:*)",
"Bash(where:*)",
"Bash(node -e:*)",
"Bash(node:*)"
]
}
}
289 changes: 289 additions & 0 deletions src/domain/curation/challenge-comparison/ChallengeCompareSection.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,289 @@
'use client';

import { useCallback, useRef, useState } from 'react';
import {
CHALLENGE_COMPARISON,
FREQUENT_COMPARISON,
} from '../shared/comparisons';
import { PROGRAMS } from '../shared/programs';
import type { ProgramId } from '../types';
import CompareResultCard from './CompareResultCard';
import RecommendedComparisons from './RecommendedComparisons';
import { useCompareCart } from './useCompareCart';

/** Figma 기반 카드별 썸네일 배경 색상 */
const CARD_COLORS: Record<ProgramId, string> = {
experience: '#ff8165',
resume: '#4d55f5',
coverLetter: '#14bcff',
portfolio: '#14bcff',
enterpriseCover: '#6cdb3f',
marketingAllInOne: '#161c2f',
hrAllInOne: '#161c2f',
};

/** 추천 비교 조합의 프로그램명 → ProgramId 매핑 */
const findProgramIdByLabel = (label: string): ProgramId | null => {
const match = CHALLENGE_COMPARISON.find((c) => c.label === label);
return match?.programId ?? null;
};

/** 체크 아이콘 SVG */
const CheckIcon = ({ className }: { className?: string }) => (
<svg
width="14"
height="14"
viewBox="0 0 14 14"
fill="none"
className={className}
>
<path
d="M2.5 7L5.5 10L11.5 4"
stroke="currentColor"
strokeWidth="2.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);

/** X 아이콘 SVG */
const CloseIcon = () => (
<svg width="12" height="12" viewBox="0 0 12 12" fill="none">
<path
d="M3 3L9 9M9 3L3 9"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
/>
</svg>
);

const ChallengeCompareSection = () => {
const {
cartItems,
toggleCartItem,
isInCart,
isFull,
canCompare,
removeFromCart,
} = useCompareCart();

const [compareTargets, setCompareTargets] = useState<ProgramId[]>([]);
const [recommendedIndex, setRecommendedIndex] = useState<number | null>(null);
const resultRef = useRef<HTMLDivElement>(null);

const scrollToResult = useCallback(() => {
requestAnimationFrame(() => {
resultRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' });
});
}, []);

/** 비교하기 바 클릭 */
const handleCompare = useCallback(() => {
if (!canCompare) return;
setCompareTargets([...cartItems]);
setRecommendedIndex(null);
scrollToResult();
}, [canCompare, cartItems, scrollToResult]);

/** 추천 비교 조합 클릭 */
const handleRecommendedSelect = useCallback(
(index: number) => {
const item = FREQUENT_COMPARISON[index];
if (!item) return;

const leftId = findProgramIdByLabel(item.left);
const rightId = findProgramIdByLabel(item.right);
if (!leftId || !rightId) return;

setCompareTargets([leftId, rightId]);
setRecommendedIndex(index);
scrollToResult();
},
[scrollToResult],
);

/** 비교 결과 닫기 */
const handleCloseResult = useCallback(() => {
setCompareTargets([]);
setRecommendedIndex(null);
}, []);

const allPrograms = CHALLENGE_COMPARISON;
const row1 = allPrograms.slice(0, 4);
const row2 = allPrograms.slice(4);
Comment on lines +113 to +115

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

4라는 숫자가 매직 넘버로 사용되었습니다. 가이드라인의 "Naming Magic Numbers" 규칙에 따라, 한 행에 표시할 아이템 개수를 의미하는 상수로 정의하여 가독성을 높여주세요.

Suggested change
const allPrograms = CHALLENGE_COMPARISON;
const row1 = allPrograms.slice(0, 4);
const row2 = allPrograms.slice(4);
const ITEMS_PER_ROW = 4;
const allPrograms = CHALLENGE_COMPARISON;
const row1 = allPrograms.slice(0, ITEMS_PER_ROW);
const row2 = allPrograms.slice(ITEMS_PER_ROW);
References
  1. 매직 넘버를 의미 있는 이름을 가진 상수로 교체하여 코드의 명확성을 높여야 합니다. (link)


const renderCard = (challenge: (typeof allPrograms)[number]) => {
const program = PROGRAMS[challenge.programId];
const inCart = isInCart(challenge.programId);
const bgColor = CARD_COLORS[challenge.programId];
const isDark = bgColor === '#161c2f';

return (
<div key={challenge.programId} className="flex w-[240px] flex-col">
{/* 카드 본체 */}
<div className="flex flex-col gap-3">
{/* 썸네일 영역 */}
<div
className="flex h-[180px] w-[240px] items-end overflow-hidden rounded-[7px] p-4"
style={{ backgroundColor: bgColor }}
>
<div className="flex flex-col gap-0.5">
<span
className={`text-xs font-medium ${isDark ? 'text-white/70' : 'text-white/80'}`}
>
{program.subtitle}
</span>
<span className="text-base font-bold text-white">
{program.title}
</span>
</div>
</div>

{/* 텍스트 영역 */}
<div className="flex flex-col gap-3 px-1">
<span className="line-clamp-2 text-lg font-bold leading-[26px] text-[#27272d]">
{program.title}
</span>
<div className="flex items-center gap-1.5">
<span className="text-[11px] font-medium leading-[14px] text-[#27272d]">
진행기간
</span>
<span className="text-[11px] font-medium leading-[14px] text-[#4138a3]">
{program.duration}
</span>
</div>
<div className="flex items-start gap-1.5">
<CheckIcon className="mt-1 shrink-0 text-[#7a7d84]" />
<span className="line-clamp-2 text-sm leading-[22px] text-[#27272d]">
{program.target}
</span>
</div>
</div>
</div>

{/* 비교함 담기 버튼 */}
<div className="mt-5 flex gap-1">
{inCart ? (
<>
<button
type="button"
onClick={() => toggleCartItem(challenge.programId)}
className="flex flex-1 items-center justify-center gap-1 rounded-lg bg-[#dbddfd] px-2 py-2.5"
>
<CheckIcon className="text-[#5f66f6]" />
<span className="text-xs font-semibold leading-4 text-[#5f66f6]">
비교함 담기
</span>
</button>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
removeFromCart(challenge.programId);
}}
className="flex w-[46px] items-center justify-center rounded-lg bg-[#e7e7e7]"
>
<CloseIcon />
</button>
</>
) : (
<button
type="button"
onClick={() => toggleCartItem(challenge.programId)}
disabled={isFull}
className={`flex w-full items-center justify-center rounded-lg px-2 py-2.5 transition-colors ${
isFull
? 'cursor-not-allowed bg-[#e7e7e7] opacity-50'
: 'bg-[#e7e7e7] hover:bg-[#dbddfd] hover:text-[#5f66f6]'
}`}
>
<span className="text-xs font-semibold leading-4 text-[#5c5f66]">
비교함 담기
</span>
</button>
)}
</div>
</div>
);
};
Comment on lines +117 to +210

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

renderCard 함수는 컴포넌트 내부에서 정의되어 있으며 UI 로직이 상당히 깁니다. 가이드라인의 "Abstracting Implementation Details" 원칙에 따라, 이를 별도의 ChallengeCard 컴포넌트로 추출하여 ChallengeCompareSection의 인지 부하를 줄이고 유지보수성을 높이는 것을 권장합니다.

References
  1. 복잡한 로직이나 인터랙션은 전용 컴포넌트로 추상화하여 관심사를 분리해야 합니다. (link)


return (
<section
className="flex w-full flex-col items-center"
id="curation-challenge-comparison"
>
{/* 섹션 헤더 */}
<div className="flex w-full flex-col items-center gap-3 pb-10 pt-[60px]">
<div className="flex w-[1000px] flex-col items-center gap-5">
<p className="text-center text-lg font-semibold leading-[26px] text-[#7177f7]">
챌린지 비교
</p>
<h3 className="text-center text-[30px] font-bold leading-[42px] text-[#27272d]">
고민되는 챌린지, 비교해보세요
</h3>
</div>
<p className="text-center text-lg font-semibold leading-[26px] text-[#5c5f66]">
많은 분들이 궁금해하는 챌린지 간 차이를 한눈에 확인하세요
</p>
</div>

{/* 메인 컨텐츠 영역 (1180px) */}
<div className="flex w-[1180px] flex-col gap-10">
{/* 추천 비교 조합 (카드 위에 배치) */}
<RecommendedComparisons
activeIndex={recommendedIndex}
onSelect={handleRecommendedSelect}
/>

{/* 챌린지 카드 — 4+3 2열 */}
<div className="flex flex-col gap-1">
{/* Row 1: 4 cards */}
<div className="flex gap-6 py-5">
{row1.map((challenge) => renderCard(challenge))}
</div>
{/* Row 2: 3 cards */}
<div className="flex gap-6 py-5">
{row2.map((challenge) => renderCard(challenge))}
</div>
</div>

{/* 비교하기 바 */}
<div className="flex flex-col items-center">
<button
type="button"
onClick={handleCompare}
disabled={!canCompare}
className={`flex w-full items-center justify-center rounded-lg px-2 py-5 transition-colors ${
canCompare
? 'cursor-pointer bg-[#5f66f6] hover:bg-[#4d55f5]'
: 'bg-[#acafb6]'
}`}
>
<span className="text-base font-semibold leading-6 text-[#fafbfd]">
{canCompare
? `프로그램 ${cartItems.length}개 비교하기`
: '비교할 프로그램을 선택해주세요'}
</span>
</button>
<p className="py-5 text-center text-xs leading-5 text-[#7a7d84]">
비교할 프로그램 2개 이상을 선택하면 비교 결과를 볼 수 있어요
</p>
</div>

{/* 비교 결과 */}
<div ref={resultRef}>
{compareTargets.length >= 2 && (
<CompareResultCard
programIds={compareTargets}
onClose={handleCloseResult}
/>
)}
</div>
</div>
</section>
);
};

export default ChallengeCompareSection;
Loading