From dfd410163f7b2cfb3209ceed7f149553d8dbcc37 Mon Sep 17 00:00:00 2001 From: sungbin Date: Tue, 24 Feb 2026 17:03:40 +0900 Subject: [PATCH 1/4] =?UTF-8?q?chore(curation):=20=EC=A0=84=EC=B2=B4=20?= =?UTF-8?q?=EB=B9=84=EA=B5=90=20=EA=B8=B0=EB=8A=A5=20=EB=B0=8F=20=EA=B4=80?= =?UTF-8?q?=EB=A0=A8=20=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8=20=EC=82=AD?= =?UTF-8?q?=EC=A0=9C,=20=EC=84=B9=EC=85=98=20=EA=B5=AC=EC=A1=B0=20?= =?UTF-8?q?=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .claude/launch.json | 11 + .claude/settings.local.json | 4 +- .../ChallengeComparisonCards.tsx | 138 -------- .../ChallengeComparisonSection.tsx | 77 ----- .../ChallengeComparisonTable.tsx | 302 ------------------ .../MobileChallengeComparison.tsx | 258 --------------- .../challenge-comparison/useCompareCart.ts | 51 +++ .../challenge-comparison/useExpandableRows.ts | 14 - .../FrequentComparisonCarousel.tsx | 175 ---------- .../FrequentComparisonSection.tsx | 25 -- .../MobileFrequentComparison.tsx | 107 ------- .../carouselAnimation.test.ts | 128 -------- .../frequent-comparison/carouselAnimation.ts | 140 -------- .../useInfiniteCarousel.ts | 157 --------- src/domain/curation/nav/CurationStickyNav.tsx | 32 +- src/domain/curation/screen/CurationScreen.tsx | 30 +- src/domain/curation/types.ts | 16 - ...53\240\210\354\235\264\354\205\230 18.png" | Bin 0 -> 802405 bytes ...53\240\210\354\235\264\354\205\230 19.png" | Bin 0 -> 690204 bytes ...1\353\241\235 \352\264\200\353\246\254.md" | 206 ++++++++++++ ...asks-prd-curation-comparison-redesign-1.md | 190 +++++++++++ 21 files changed, 483 insertions(+), 1578 deletions(-) create mode 100644 .claude/launch.json delete mode 100644 src/domain/curation/challenge-comparison/ChallengeComparisonCards.tsx delete mode 100644 src/domain/curation/challenge-comparison/ChallengeComparisonSection.tsx delete mode 100644 src/domain/curation/challenge-comparison/ChallengeComparisonTable.tsx delete mode 100644 src/domain/curation/challenge-comparison/MobileChallengeComparison.tsx create mode 100644 src/domain/curation/challenge-comparison/useCompareCart.ts delete mode 100644 src/domain/curation/challenge-comparison/useExpandableRows.ts delete mode 100644 src/domain/curation/frequent-comparison/FrequentComparisonCarousel.tsx delete mode 100644 src/domain/curation/frequent-comparison/FrequentComparisonSection.tsx delete mode 100644 src/domain/curation/frequent-comparison/MobileFrequentComparison.tsx delete mode 100644 src/domain/curation/frequent-comparison/carouselAnimation.test.ts delete mode 100644 src/domain/curation/frequent-comparison/carouselAnimation.ts delete mode 100644 src/domain/curation/frequent-comparison/useInfiniteCarousel.ts create mode 100644 "tasks/figma/\355\201\220\353\240\210\354\235\264\354\205\230 18.png" create mode 100644 "tasks/figma/\355\201\220\353\240\210\354\235\264\354\205\230 19.png" create mode 100644 "tasks/prompt/\354\236\221\354\227\205 \353\252\251\353\241\235 \352\264\200\353\246\254.md" create mode 100644 tasks/tasks-prd-curation-comparison-redesign-1.md diff --git a/.claude/launch.json b/.claude/launch.json new file mode 100644 index 000000000..5b3bebd2d --- /dev/null +++ b/.claude/launch.json @@ -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 + } + ] +} diff --git a/.claude/settings.local.json b/.claude/settings.local.json index a1709b8ed..ae664878e 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -4,7 +4,9 @@ "Bash(npx tsc:*)", "Bash(xargs:*)", "Bash(git mv:*)", - "Bash(npx vitest:*)" + "Bash(npx vitest:*)", + "mcp__Claude_Preview__preview_start", + "Bash(curl:*)" ] } } diff --git a/src/domain/curation/challenge-comparison/ChallengeComparisonCards.tsx b/src/domain/curation/challenge-comparison/ChallengeComparisonCards.tsx deleted file mode 100644 index 0653b43b5..000000000 --- a/src/domain/curation/challenge-comparison/ChallengeComparisonCards.tsx +++ /dev/null @@ -1,138 +0,0 @@ -import { PROGRAMS } from '../shared/programs'; -import type { - ChallengeComparisonRow, - ComparisonRowConfig, - ProgramId, -} from '../types'; - -interface ChallengeComparisonCardsProps { - challenges: ChallengeComparisonRow[]; - rows: ComparisonRowConfig[]; - expandedRows: Record; - toggleRow: (key: string) => void; - highlightedPrograms?: { - primary: ProgramId | null; - secondary: ProgramId[]; - }; -} - -const ChallengeComparisonCards = ({ - challenges, - rows, - expandedRows, - toggleRow, - highlightedPrograms = { primary: null, secondary: [] }, -}: ChallengeComparisonCardsProps) => { - return ( -
- {challenges.map((challenge) => { - const program = PROGRAMS[challenge.programId]; - const isPrimary = highlightedPrograms.primary === challenge.programId; - const isSecondary = highlightedPrograms.secondary.includes( - challenge.programId, - ); - const isHighlighted = isPrimary || isSecondary; - return ( -
-
- {isHighlighted && ( - - 추천 - - )} - - {program.title} - - - {program.subtitle} - -
- {rows.map((row) => { - const value = challenge[row.key]; - const displayValue = value || '-'; - const isCollapsible = row.collapsible; - const isDefaultHidden = row.defaultHidden; - const rowKey = `mobile-${row.key}`; - const isExpanded = expandedRows[rowKey]; - - if (isDefaultHidden && !isExpanded) { - return ( -
- -
- ); - } - - return ( -
-
- - {row.label} - - {isCollapsible && ( - - )} -
- - {row.key === 'pricing' ? ( - <> - {displayValue.split('\n').map((line, i) => ( - - {line.includes('(환급금 없음)') ? ( - - {line} - - ) : ( - line - )} - {i < displayValue.split('\n').length - 1 &&
} -
- ))} - - ) : row.key === 'deliverable' && - !isExpanded && - displayValue !== '-' ? ( - displayValue.split('\n\n')[0] - ) : ( - displayValue - )} -
-
- ); - })} -
- ); - })} -
- ); -}; - -export default ChallengeComparisonCards; diff --git a/src/domain/curation/challenge-comparison/ChallengeComparisonSection.tsx b/src/domain/curation/challenge-comparison/ChallengeComparisonSection.tsx deleted file mode 100644 index dcb576dc1..000000000 --- a/src/domain/curation/challenge-comparison/ChallengeComparisonSection.tsx +++ /dev/null @@ -1,77 +0,0 @@ -'use client'; - -import { CHALLENGE_COMPARISON } from '../shared/comparisons'; -import type { ComparisonRowConfig, ProgramId } from '../types'; -import ChallengeComparisonTable from './ChallengeComparisonTable'; -import MobileChallengeComparison from './MobileChallengeComparison'; -import { useExpandableRows } from './useExpandableRows'; - -const COMPARISON_ROWS: ComparisonRowConfig[] = [ - { label: '추천 대상', key: 'target' }, - { label: '기간', key: 'duration' }, - { label: '플랜별 가격\n(환급금 기준)', key: 'pricing' }, - { label: '피드백 횟수', key: 'feedback' }, - { label: '결과물', key: 'deliverable', collapsible: true }, - { - label: '커리큘럼', - key: 'curriculum', - collapsible: true, - defaultHidden: true, - }, - { - label: '주요 특징', - key: 'features', - collapsible: true, - defaultHidden: true, - }, -]; - -interface ChallengeComparisonSectionProps { - highlightedPrograms?: { - primary: ProgramId | null; - secondary: ProgramId[]; - }; -} - -const ChallengeComparisonSection = ({ - highlightedPrograms = { primary: null, secondary: [] }, -}: ChallengeComparisonSectionProps) => { - const { expandedRows, toggleRow } = useExpandableRows(); - - return ( -
-
-

- 어떤 챌린지가 나에게 맞을까? -

-

- 가격부터 결과물까지, 한눈에 비교하고 나에게 딱 맞는 챌린지를 골라보세요. -

-
- - {/* 데스크탑 테이블 */} -
- -
- - {/* 모바일 비교표 */} -
- -
-
- ); -}; - -export default ChallengeComparisonSection; diff --git a/src/domain/curation/challenge-comparison/ChallengeComparisonTable.tsx b/src/domain/curation/challenge-comparison/ChallengeComparisonTable.tsx deleted file mode 100644 index 1d30d4976..000000000 --- a/src/domain/curation/challenge-comparison/ChallengeComparisonTable.tsx +++ /dev/null @@ -1,302 +0,0 @@ -import { PROGRAMS } from '../shared/programs'; -import type { - ChallengeComparisonRow, - ComparisonRowConfig, - ProgramId, -} from '../types'; - -interface ChallengeComparisonTableProps { - challenges: ChallengeComparisonRow[]; - rows: ComparisonRowConfig[]; - expandedRows: Record; - toggleRow: (key: string) => void; - highlightedPrograms?: { - primary: ProgramId | null; - secondary: ProgramId[]; - }; -} - -const ChallengeComparisonTable = ({ - challenges, - rows, - expandedRows, - toggleRow, - highlightedPrograms = { primary: null, secondary: [] }, -}: ChallengeComparisonTableProps) => { - return ( -
-
- - - - - {challenges.map((challenge) => { - const program = PROGRAMS[challenge.programId]; - const isPrimary = - highlightedPrograms.primary === challenge.programId; - const isSecondary = highlightedPrograms.secondary.includes( - challenge.programId, - ); - const isHighlighted = isPrimary || isSecondary; - return ( - - ); - })} - - - - {rows.map((row, idx) => { - const isCollapsible = row.collapsible; - const isDefaultHidden = row.defaultHidden; - const isExpanded = expandedRows[row.key]; - - if (isDefaultHidden && !isExpanded) { - return ( - - - - ); - } - - // 커리큘럼 행은 2개의 실제 행으로 분리 - if (row.key === 'curriculum') { - return ( - <> - {/* 단계 행 */} - - - {challenges.map((challenge) => { - const value = challenge[row.key]; - const displayValue = value || '-'; - const isPrimary = - highlightedPrograms.primary === challenge.programId; - const isSecondary = highlightedPrograms.secondary.includes( - challenge.programId, - ); - - let contentToShow = displayValue; - let techniquesSection = ''; - - if (displayValue !== '-') { - const parts = displayValue.split('\n\n'); - contentToShow = parts[0]; // 단계 부분 - if (parts[1]) { - techniquesSection = parts[1]; // 사용기법/템플릿/특별미션 부분 - } - } - - return ( - - ); - })} - - {/* 사용기법 행 */} - - {challenges.map((challenge) => { - const value = challenge[row.key]; - const displayValue = value || '-'; - const isPrimary = - highlightedPrograms.primary === challenge.programId; - const isSecondary = highlightedPrograms.secondary.includes( - challenge.programId, - ); - - let techniquesSection = ''; - - if (displayValue !== '-') { - const parts = displayValue.split('\n\n'); - if (parts[1]) { - techniquesSection = parts[1]; - } - } - - return ( - - ); - })} - - - ); - } - - return ( - - - {challenges.map((challenge) => { - const value = challenge[row.key]; - const displayValue = value || '-'; - const isPrimary = - highlightedPrograms.primary === challenge.programId; - const isSecondary = highlightedPrograms.secondary.includes( - challenge.programId, - ); - - let contentToShow = displayValue; - - if ( - row.key === 'deliverable' && - !isExpanded && - displayValue !== '-' - ) { - const parts = displayValue.split('\n\n'); - contentToShow = parts[0]; - } - - return ( - - ); - })} - - ); - })} - -
- 구분 - -
- {isHighlighted && ( - - 추천 - - )} - - {program.title} - - - {program.subtitle} - -
-
- -
-
- {row.label} - {isCollapsible && ( - - )} -
-
-
- {contentToShow} -
-
-
- {techniquesSection || '\u00A0'} -
-
-
- {row.label} - {isCollapsible && ( - - )} -
-
-
- {row.key === 'pricing' ? ( - <> - {contentToShow.split('\n').map((line, i) => ( - - {line.includes('(환급금 없음)') ? ( - - {line} - - ) : ( - line - )} - {i < contentToShow.split('\n').length - 1 && ( -
- )} -
- ))} - - ) : ( - contentToShow - )} -
-
-
-
- ); -}; - -export default ChallengeComparisonTable; diff --git a/src/domain/curation/challenge-comparison/MobileChallengeComparison.tsx b/src/domain/curation/challenge-comparison/MobileChallengeComparison.tsx deleted file mode 100644 index 67422d7f5..000000000 --- a/src/domain/curation/challenge-comparison/MobileChallengeComparison.tsx +++ /dev/null @@ -1,258 +0,0 @@ -import { useState } from 'react'; -import { PROGRAMS } from '../shared/programs'; -import type { ChallengeComparisonRow, ProgramId } from '../types'; - -interface MobileChallengeComparisonProps { - challenges: ChallengeComparisonRow[]; - highlightedPrograms?: { - primary: ProgramId | null; - secondary: ProgramId[]; - }; -} - -const MobileChallengeComparison = ({ - challenges, - highlightedPrograms = { primary: null, secondary: [] }, -}: MobileChallengeComparisonProps) => { - // 카드 내부 섹션 펼치기/접기 - const [expandedCards, setExpandedCards] = useState>( - {}, - ); - - // 전체 카드 펼치기/접기 (추천되지 않은 카드는 기본 접혀있음) - const [expandedChallenges, setExpandedChallenges] = useState< - Record - >(() => { - const initial: Record = {}; - challenges.forEach((challenge) => { - const isPrimary = highlightedPrograms.primary === challenge.programId; - const isSecondary = highlightedPrograms.secondary.includes( - challenge.programId, - ); - // 추천된 프로그램만 펼쳐져 있음 - initial[challenge.programId] = isPrimary || isSecondary; - }); - return initial; - }); - - const toggleChallenge = (programId: string) => { - setExpandedChallenges((prev) => ({ - ...prev, - [programId]: !prev[programId], - })); - }; - - const toggleCard = (programId: string, section: string) => { - const key = `${programId}-${section}`; - setExpandedCards((prev) => ({ ...prev, [key]: !prev[key] })); - }; - - const isExpanded = (programId: string, section: string) => { - return expandedCards[`${programId}-${section}`] || false; - }; - - const isChallengeExpanded = (programId: string) => { - return expandedChallenges[programId] || false; - }; - - return ( -
- {challenges.map((challenge) => { - const program = PROGRAMS[challenge.programId]; - const isPrimary = highlightedPrograms.primary === challenge.programId; - const isSecondary = highlightedPrograms.secondary.includes( - challenge.programId, - ); - const isHighlighted = isPrimary || isSecondary; - const isCardExpanded = isChallengeExpanded(challenge.programId); - - return ( -
- {/* 헤더 - 클릭하여 펼치기/접기 */} - - - {/* 펼쳐진 내용 */} - {isCardExpanded && ( -
- {/* 핵심 정보 */} -
-
- - 추천 대상 - - - {challenge.target} - -
-
- - 기간 - - - {challenge.duration} - -
-
- - 피드백 - - - {challenge.feedback} - -
-
- - {/* 가격 정보 */} -
- - 플랜별 가격 - -
- {challenge.pricing.split('\n').map((line, i) => ( - - {line} - - ))} -
-
- - {/* 결과물 */} -
-
- - 결과물 - - -
-

- {challenge.deliverable} -

-
- - {/* 커리큘럼 & 특징 (접기/펼치기) */} -
- {/* 커리큘럼 */} - {challenge.curriculum && ( -
- - {isExpanded(challenge.programId, 'curriculum') && ( -

- {challenge.curriculum} -

- )} -
- )} - - {/* 주요 특징 */} - {challenge.features && ( -
- - {isExpanded(challenge.programId, 'features') && ( -

- {challenge.features} -

- )} -
- )} -
-
- )} -
- ); - })} -
- ); -}; - -export default MobileChallengeComparison; diff --git a/src/domain/curation/challenge-comparison/useCompareCart.ts b/src/domain/curation/challenge-comparison/useCompareCart.ts new file mode 100644 index 000000000..aeeaa335b --- /dev/null +++ b/src/domain/curation/challenge-comparison/useCompareCart.ts @@ -0,0 +1,51 @@ +import { useCallback, useState } from 'react'; +import type { ProgramId } from '../types'; + +const MAX_COMPARE_ITEMS = 3; + +export const useCompareCart = () => { + const [cartItems, setCartItems] = useState([]); + + const addToCart = useCallback((id: ProgramId) => { + setCartItems((prev) => { + if (prev.includes(id)) return prev; + if (prev.length >= MAX_COMPARE_ITEMS) return prev; + return [...prev, id]; + }); + }, []); + + const removeFromCart = useCallback((id: ProgramId) => { + setCartItems((prev) => prev.filter((item) => item !== id)); + }, []); + + const toggleCartItem = useCallback((id: ProgramId) => { + setCartItems((prev) => { + if (prev.includes(id)) return prev.filter((item) => item !== id); + if (prev.length >= MAX_COMPARE_ITEMS) return prev; + return [...prev, id]; + }); + }, []); + + const clearCart = useCallback(() => { + setCartItems([]); + }, []); + + const isInCart = useCallback( + (id: ProgramId) => cartItems.includes(id), + [cartItems], + ); + + const isFull = cartItems.length >= MAX_COMPARE_ITEMS; + const canCompare = cartItems.length >= 2; + + return { + cartItems, + addToCart, + removeFromCart, + toggleCartItem, + clearCart, + isInCart, + isFull, + canCompare, + }; +}; diff --git a/src/domain/curation/challenge-comparison/useExpandableRows.ts b/src/domain/curation/challenge-comparison/useExpandableRows.ts deleted file mode 100644 index 493a3218a..000000000 --- a/src/domain/curation/challenge-comparison/useExpandableRows.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { useCallback, useState } from 'react'; - -export function useExpandableRows() { - const [expandedRows, setExpandedRows] = useState>({}); - - const toggleRow = useCallback((rowKey: string) => { - setExpandedRows((prev) => ({ - ...prev, - [rowKey]: !prev[rowKey], - })); - }, []); - - return { expandedRows, toggleRow }; -} diff --git a/src/domain/curation/frequent-comparison/FrequentComparisonCarousel.tsx b/src/domain/curation/frequent-comparison/FrequentComparisonCarousel.tsx deleted file mode 100644 index abf0383a0..000000000 --- a/src/domain/curation/frequent-comparison/FrequentComparisonCarousel.tsx +++ /dev/null @@ -1,175 +0,0 @@ -'use client'; - -import { FREQUENT_COMPARISON } from '../shared/comparisons'; -import type { FrequentComparisonItem } from '../types'; -import { useInfiniteCarousel } from './useInfiniteCarousel'; - -const FrequentComparisonCarousel = () => { - const { - infiniteItems, - scrollContainerRef, - setItemRef, - activeIndex, - handleNavigation, - getItemStyle, - scrollToAndActivate, - } = useInfiniteCarousel({ - items: FREQUENT_COMPARISON, - }); - - return ( -
-
-

- 고민되는 챌린지, 비교해보세요 -

-

- 많은 분들이 궁금해하는 챌린지 간 차이를 한눈에 확인하세요. -

-
- - {/* 스크롤 컨테이너와 화살표 버튼 */} -
- {/* 왼쪽 화살표 */} - - - {/* 오른쪽 화살표 */} - - - {/* 스크롤 가능한 컨테이너 */} -
- {/* 왼쪽 그라데이션 오버레이 - 중앙 카드에 영향 없도록 */} -
- - {/* 오른쪽 그라데이션 오버레이 - 중앙 카드에 영향 없도록 */} -
- -
- {infiniteItems.map((item, index) => { - const itemStyle = getItemStyle(index); - const isActive = index === activeIndex; - - return ( -
{ - setItemRef(index, el); - }} - onClick={() => { - if (!isActive) { - scrollToAndActivate(index); - } - }} - className={`flex min-w-[85%] snap-center flex-col gap-4 rounded-xl border-2 bg-white p-5 shadow-sm transition-all duration-500 ease-out sm:min-w-[75%] md:min-w-[60%] lg:min-w-[480px] ${ - isActive - ? 'cursor-default border-primary-dark shadow-lg' - : 'cursor-pointer border-neutral-90 hover:border-primary-light' - }`} - style={{ - opacity: itemStyle.opacity, - transform: `scale(${itemStyle.scale})`, - pointerEvents: itemStyle.opacity < 0.2 ? 'none' : 'auto', - }} - > - {/* 비교 제목 */} -
- {item.title} -
- - {/* 챌린지 이름 헤더 */} -
-
-
- {item.left} -
-
-
-
- {item.right} -
-
-
- - {/* 비교 항목 */} -
- {item.rows.map((row) => ( -
-
-

- {row.label} -

-
-
-
-

- {row.left} -

-
-
-

- {row.right} -

-
-
-
- ))} -
-
- ); - })} -
-
-
-
- ); -}; - -export default FrequentComparisonCarousel; diff --git a/src/domain/curation/frequent-comparison/FrequentComparisonSection.tsx b/src/domain/curation/frequent-comparison/FrequentComparisonSection.tsx deleted file mode 100644 index 58f61d46a..000000000 --- a/src/domain/curation/frequent-comparison/FrequentComparisonSection.tsx +++ /dev/null @@ -1,25 +0,0 @@ -'use client'; - -import FrequentComparisonCarousel from './FrequentComparisonCarousel'; -import MobileFrequentComparison from './MobileFrequentComparison'; - -const FrequentComparisonSection = () => { - return ( -
- {/* 데스크톱 캐러셀 */} -
- -
- - {/* 모바일 아코디언 */} -
- -
-
- ); -}; - -export default FrequentComparisonSection; diff --git a/src/domain/curation/frequent-comparison/MobileFrequentComparison.tsx b/src/domain/curation/frequent-comparison/MobileFrequentComparison.tsx deleted file mode 100644 index 4a7c3a262..000000000 --- a/src/domain/curation/frequent-comparison/MobileFrequentComparison.tsx +++ /dev/null @@ -1,107 +0,0 @@ -'use client'; - -import { useState } from 'react'; -import { FREQUENT_COMPARISON } from '../shared/comparisons'; - -const MobileFrequentComparison = () => { - const [expandedIndex, setExpandedIndex] = useState(0); - - const toggleExpand = (index: number) => { - setExpandedIndex(expandedIndex === index ? null : index); - }; - - return ( -
-
-

- 고민되는 챌린지, 비교해보세요 -

-

- 많은 분들이 궁금해하는 챌린지 간 차이를 한눈에 확인하세요. -

-
- -
- {FREQUENT_COMPARISON.map((item, index) => { - const isExpanded = expandedIndex === index; - - return ( -
- {/* 헤더 - 클릭하여 펼치기/접기 */} - - - {/* 펼쳐진 내용 */} - {isExpanded && ( -
- {/* 챌린지 이름 */} -
-
-

- {item.left} -

-
-
-

- {item.right} -

-
-
- - {/* 비교 항목들 */} -
- {item.rows.map((row) => ( -
- - {row.label} - -
-
-

- {row.left} -

-
-
-

- {row.right} -

-
-
-
- ))} -
-
- )} -
- ); - })} -
-
- ); -}; - -export default MobileFrequentComparison; diff --git a/src/domain/curation/frequent-comparison/carouselAnimation.test.ts b/src/domain/curation/frequent-comparison/carouselAnimation.test.ts deleted file mode 100644 index 59adf9c92..000000000 --- a/src/domain/curation/frequent-comparison/carouselAnimation.test.ts +++ /dev/null @@ -1,128 +0,0 @@ -import { describe, expect, it } from 'vitest'; - -import { - calculateCenterScrollLeft, - calculateItemStyle, - findClosestItemIndex, - getContainerMetrics, - getItemMetrics, -} from './carouselAnimation'; - -describe('calculateItemStyle', () => { - const container = getContainerMetrics({ - left: 0, - right: 1000, - width: 1000, - top: 0, - bottom: 0, - height: 0, - x: 0, - y: 0, - toJSON: () => {}, - } as DOMRect); - - it('중앙에 위치한 아이템은 opacity 1, scale 1', () => { - const item = getItemMetrics({ - left: 450, - right: 550, - width: 100, - top: 0, - bottom: 0, - height: 0, - x: 450, - y: 0, - toJSON: () => {}, - } as DOMRect); - - const style = calculateItemStyle(container, item); - expect(style.opacity).toBe(1.0); - expect(style.scale).toBe(1.0); - }); - - it('화면 50% 이상 떨어진 아이템은 opacity 0, scale 0.85', () => { - // 아이템 중심 = 1050, 컨테이너 중심 = 500 → 거리 550 > halfScreenWidth(500) - const item = getItemMetrics({ - left: 1000, - right: 1100, - width: 100, - top: 0, - bottom: 0, - height: 0, - x: 1000, - y: 0, - toJSON: () => {}, - } as DOMRect); - - const style = calculateItemStyle(container, item); - expect(style.opacity).toBe(0); - expect(style.scale).toBe(0.85); - }); - - it('중간 거리의 아이템은 점진적으로 페이드', () => { - // 중앙에서 약 35% 떨어진 위치 (25%~50% 구간) - const item = getItemMetrics({ - left: 800, - right: 900, - width: 100, - top: 0, - bottom: 0, - height: 0, - x: 800, - y: 0, - toJSON: () => {}, - } as DOMRect); - - const style = calculateItemStyle(container, item); - expect(style.opacity).toBeGreaterThan(0); - expect(style.opacity).toBeLessThan(1); - expect(style.scale).toBeGreaterThan(0.85); - expect(style.scale).toBeLessThan(1); - }); -}); - -describe('findClosestItemIndex', () => { - it('컨테이너 중앙에 가장 가까운 아이템의 인덱스를 반환', () => { - const containerCenter = 500; - - const items = [ - { getBoundingClientRect: () => ({ left: 100, width: 100 }) }, - { getBoundingClientRect: () => ({ left: 450, width: 100 }) }, - { getBoundingClientRect: () => ({ left: 800, width: 100 }) }, - ] as HTMLElement[]; - - const index = findClosestItemIndex(containerCenter, items); - expect(index).toBe(1); - }); - - it('null 아이템은 건너뜀', () => { - const containerCenter = 500; - - const items: (HTMLElement | null)[] = [ - null, - { - getBoundingClientRect: () => ({ left: 450, width: 100 }), - } as HTMLElement, - null, - ]; - - const index = findClosestItemIndex(containerCenter, items); - expect(index).toBe(1); - }); -}); - -describe('calculateCenterScrollLeft', () => { - it('아이템을 컨테이너 중앙에 맞추기 위한 scrollLeft 값 계산', () => { - const container = { - getBoundingClientRect: () => ({ left: 0, width: 1000 }), - scrollLeft: 200, - } as unknown as HTMLElement; - - const target = { - getBoundingClientRect: () => ({ left: 300, width: 200 }), - } as unknown as HTMLElement; - - // scrollLeft = 300 - 0 + 200 - (1000 - 200) / 2 = 500 - 400 = 100 - const result = calculateCenterScrollLeft(container, target); - expect(result).toBe(100); - }); -}); diff --git a/src/domain/curation/frequent-comparison/carouselAnimation.ts b/src/domain/curation/frequent-comparison/carouselAnimation.ts deleted file mode 100644 index 0f7f39e47..000000000 --- a/src/domain/curation/frequent-comparison/carouselAnimation.ts +++ /dev/null @@ -1,140 +0,0 @@ -export interface CarouselItemStyle { - opacity: number; - scale: number; -} - -interface ContainerMetrics { - left: number; - right: number; - width: number; - center: number; -} - -interface ItemMetrics { - left: number; - right: number; - center: number; -} - -export function getContainerMetrics(rect: DOMRect): ContainerMetrics { - return { - left: rect.left, - right: rect.right, - width: rect.width, - center: rect.left + rect.width / 2, - }; -} - -export function getItemMetrics(rect: DOMRect): ItemMetrics { - return { - left: rect.left, - right: rect.right, - center: rect.left + rect.width / 2, - }; -} - -/** - * 중앙에서의 거리에 따른 투명도와 크기 계산 (양방향 그라데이션) - */ -export function calculateItemStyle( - container: ContainerMetrics, - item: ItemMetrics, -): CarouselItemStyle { - const distanceFromCenter = item.center - container.center; - const absDistance = Math.abs(distanceFromCenter); - - let opacity = 1.0; - let scale = 1.0; - - if (absDistance < 50) { - // 중앙 카드 (거의 중앙) - return { opacity: 1.0, scale: 1.0 }; - } - - const halfScreenWidth = container.width * 0.5; - - if (absDistance < halfScreenWidth * 0.5) { - // 중앙에서 화면의 25% 이내 - 선명하게 유지 - opacity = 1.0; - scale = 1.0; - } else if (absDistance < halfScreenWidth) { - // 중앙에서 25%~50% 사이 - 급격하게 페이드 - const fadeProgress = - (absDistance - halfScreenWidth * 0.5) / (halfScreenWidth * 0.5); - opacity = Math.max(0, 1 - Math.pow(fadeProgress, 1.8)); - scale = Math.max(0.85, 1 - fadeProgress * 0.15); - } else { - // 중앙에서 50% 이상 - 거의 보이지 않음 - opacity = 0; - scale = 0.85; - } - - // 양옆 끝에서 추가 페이드 (화면 끝에 가까울수록) - const edgeFadeZone = container.width * 0.25; - - if (distanceFromCenter < 0) { - // 왼쪽 카드 - const distanceFromLeftEdge = item.right - container.left; - if (distanceFromLeftEdge < edgeFadeZone) { - opacity = Math.min( - opacity, - Math.max(0, distanceFromLeftEdge / edgeFadeZone), - ); - } - } else { - // 오른쪽 카드 - const distanceFromRightEdge = container.right - item.left; - if (distanceFromRightEdge < edgeFadeZone) { - opacity = Math.min( - opacity, - Math.max(0, distanceFromRightEdge / edgeFadeZone), - ); - } - } - - return { opacity, scale }; -} - -/** - * 스크롤 컨테이너 중앙에 가장 가까운 아이템의 인덱스를 찾음 - */ -export function findClosestItemIndex( - containerCenter: number, - items: (HTMLElement | null)[], -): number { - let closestIndex = 0; - let closestDistance = Infinity; - - items.forEach((item, index) => { - if (item) { - const itemRect = item.getBoundingClientRect(); - const itemCenter = itemRect.left + itemRect.width / 2; - const distance = Math.abs(containerCenter - itemCenter); - - if (distance < closestDistance) { - closestDistance = distance; - closestIndex = index; - } - } - }); - - return closestIndex; -} - -/** - * 아이템을 컨테이너 중앙에 위치시키기 위한 scrollLeft 값 계산 - */ -export function calculateCenterScrollLeft( - container: HTMLElement, - target: HTMLElement, -): number { - const containerRect = container.getBoundingClientRect(); - const targetRect = target.getBoundingClientRect(); - - return ( - targetRect.left - - containerRect.left + - container.scrollLeft - - (containerRect.width - targetRect.width) / 2 - ); -} diff --git a/src/domain/curation/frequent-comparison/useInfiniteCarousel.ts b/src/domain/curation/frequent-comparison/useInfiniteCarousel.ts deleted file mode 100644 index f4ec2ed77..000000000 --- a/src/domain/curation/frequent-comparison/useInfiniteCarousel.ts +++ /dev/null @@ -1,157 +0,0 @@ -import { useCallback, useEffect, useRef, useState } from 'react'; - -import { - calculateCenterScrollLeft, - calculateItemStyle, - type CarouselItemStyle, - findClosestItemIndex, - getContainerMetrics, - getItemMetrics, -} from './carouselAnimation'; - -interface UseInfiniteCarouselOptions { - items: T[]; - repeatCount?: number; -} - -export function useInfiniteCarousel({ - items, - repeatCount = 31, -}: UseInfiniteCarouselOptions) { - const totalItems = items.length; - const infiniteItems = Array(repeatCount).fill(items).flat() as T[]; - - const scrollContainerRef = useRef(null); - const itemRefs = useRef<(HTMLDivElement | null)[]>([]); - const [activeIndex, setActiveIndex] = useState(0); - const [, setScrollTrigger] = useState(0); - - const setItemRef = useCallback((index: number, el: HTMLDivElement | null) => { - itemRefs.current[index] = el; - }, []); - - const scrollToIndex = useCallback( - (index: number, behavior: ScrollBehavior = 'smooth') => { - const target = itemRefs.current[index]; - const container = scrollContainerRef.current; - if (!target || !container) return; - - container.scrollTo({ - left: calculateCenterScrollLeft(container, target), - behavior, - }); - }, - [], - ); - - const handleNavigation = useCallback( - (direction: 'left' | 'right') => { - const newIndex = activeIndex + (direction === 'right' ? 1 : -1); - setActiveIndex(newIndex); - scrollToIndex(newIndex); - }, - [activeIndex, scrollToIndex], - ); - - const scrollToAndActivate = useCallback( - (index: number) => { - setActiveIndex(index); - scrollToIndex(index); - }, - [scrollToIndex], - ); - - const getItemStyle = useCallback((index: number): CarouselItemStyle => { - const container = scrollContainerRef.current; - const item = itemRefs.current[index]; - if (!container || !item) return { opacity: 0, scale: 0.85 }; - - return calculateItemStyle( - getContainerMetrics(container.getBoundingClientRect()), - getItemMetrics(item.getBoundingClientRect()), - ); - }, []); - - // 초기 중앙 위치로 스크롤 - useEffect(() => { - const middleSet = Math.floor(repeatCount / 2); - const initialIndex = middleSet * totalItems; - setActiveIndex(initialIndex); - setTimeout(() => { - scrollToIndex(initialIndex); - }, 100); - }, [totalItems, repeatCount, scrollToIndex]); - - // 스크롤 이벤트 감지하여 activeIndex 업데이트 및 무한 스크롤 처리 - useEffect(() => { - const container = scrollContainerRef.current; - if (!container) return; - - let isRelocating = false; - - const handleScroll = () => { - if (isRelocating) return; - - const containerRect = container.getBoundingClientRect(); - const containerCenter = containerRect.left + containerRect.width / 2; - - const closestIndex = findClosestItemIndex( - containerCenter, - itemRefs.current, - ); - - setActiveIndex(closestIndex); - setScrollTrigger((prev) => prev + 1); - - // 경계에 가까워지면 중간으로 순간 이동 - const safeZoneStart = totalItems * 3; - const safeZoneEnd = totalItems * (repeatCount - 3); - - if (closestIndex < safeZoneStart) { - const equivalentIndex = - closestIndex + totalItems * Math.floor(repeatCount / 2); - const targetRef = itemRefs.current[equivalentIndex]; - if (targetRef) { - isRelocating = true; - container.scrollTo({ - left: calculateCenterScrollLeft(container, targetRef), - behavior: 'auto', - }); - setActiveIndex(equivalentIndex); - setTimeout(() => { - isRelocating = false; - }, 100); - } - } else if (closestIndex >= safeZoneEnd) { - const offsetInSet = closestIndex % totalItems; - const equivalentIndex = - totalItems * Math.floor(repeatCount / 2) + offsetInSet; - const targetRef = itemRefs.current[equivalentIndex]; - if (targetRef) { - isRelocating = true; - container.scrollTo({ - left: calculateCenterScrollLeft(container, targetRef), - behavior: 'auto', - }); - setActiveIndex(equivalentIndex); - setTimeout(() => { - isRelocating = false; - }, 100); - } - } - }; - - container.addEventListener('scroll', handleScroll, { passive: true }); - return () => container.removeEventListener('scroll', handleScroll); - }, [totalItems, repeatCount]); - - return { - infiniteItems, - scrollContainerRef, - setItemRef, - activeIndex, - handleNavigation, - getItemStyle, - scrollToAndActivate, - }; -} diff --git a/src/domain/curation/nav/CurationStickyNav.tsx b/src/domain/curation/nav/CurationStickyNav.tsx index 8b423068e..99c79f767 100644 --- a/src/domain/curation/nav/CurationStickyNav.tsx +++ b/src/domain/curation/nav/CurationStickyNav.tsx @@ -4,29 +4,31 @@ import { useEffect, useState } from 'react'; interface NavItem { title: string; + sectionId: string; onClick: () => void; } interface CurationStickyNavProps { onScrollToForm: () => void; onScrollToChallengeComparison: () => void; - onScrollToFrequentComparison: () => void; onScrollToFaq: () => void; } const CurationStickyNav = ({ onScrollToForm, onScrollToChallengeComparison, - onScrollToFrequentComparison, onScrollToFaq, }: CurationStickyNavProps) => { const [activeSection, setActiveSection] = useState('form'); const navItems: NavItem[] = [ - { title: '맞춤 추천', onClick: onScrollToForm }, - { title: '챌린지 비교', onClick: onScrollToChallengeComparison }, - { title: '챌린지 차이점', onClick: onScrollToFrequentComparison }, - { title: 'FAQ', onClick: onScrollToFaq }, + { title: '맞춤 추천', sectionId: 'form', onClick: onScrollToForm }, + { + title: '챌린지 비교', + sectionId: 'challenge-comparison', + onClick: onScrollToChallengeComparison, + }, + { title: 'FAQ', sectionId: 'faq', onClick: onScrollToFaq }, ]; // 섹션 감지 @@ -39,15 +41,13 @@ const CurationStickyNav = ({ if (id === 'curation-form') setActiveSection('form'); else if (id === 'curation-challenge-comparison') setActiveSection('challenge-comparison'); - else if (id === 'curation-frequent-comparison') - setActiveSection('frequent-comparison'); else if (id === 'curation-faq') setActiveSection('faq'); } }); }, { root: null, - rootMargin: '-80px 0px -50% 0px', // sticky nav 높이를 고려한 상단 마진 + rootMargin: '-80px 0px -50% 0px', threshold: 0, }, ); @@ -56,23 +56,17 @@ const CurationStickyNav = ({ const challengeComparisonSection = document.getElementById( 'curation-challenge-comparison', ); - const frequentComparisonSection = document.getElementById( - 'curation-frequent-comparison', - ); const faqSection = document.getElementById('curation-faq'); if (formSection) observer.observe(formSection); if (challengeComparisonSection) observer.observe(challengeComparisonSection); - if (frequentComparisonSection) observer.observe(frequentComparisonSection); if (faqSection) observer.observe(faqSection); return () => { if (formSection) observer.unobserve(formSection); if (challengeComparisonSection) observer.unobserve(challengeComparisonSection); - if (frequentComparisonSection) - observer.unobserve(frequentComparisonSection); if (faqSection) observer.unobserve(faqSection); }; }, []); @@ -80,12 +74,8 @@ const CurationStickyNav = ({ return (