diff --git a/apps/web/public/mock/wish/wish-1.jpg b/apps/web/public/mock/wish/wish-1.jpg new file mode 100644 index 000000000..16ea3606b Binary files /dev/null and b/apps/web/public/mock/wish/wish-1.jpg differ diff --git a/apps/web/public/mock/wish/wish-2.jpg b/apps/web/public/mock/wish/wish-2.jpg new file mode 100644 index 000000000..6fb15c425 Binary files /dev/null and b/apps/web/public/mock/wish/wish-2.jpg differ diff --git a/apps/web/public/mock/wish/wish-3.jpg b/apps/web/public/mock/wish/wish-3.jpg new file mode 100644 index 000000000..47277c1db Binary files /dev/null and b/apps/web/public/mock/wish/wish-3.jpg differ diff --git a/apps/web/public/mock/wish/wish-4.jpg b/apps/web/public/mock/wish/wish-4.jpg new file mode 100644 index 000000000..8db67572a Binary files /dev/null and b/apps/web/public/mock/wish/wish-4.jpg differ diff --git a/apps/web/public/mock/wish/wish-5.jpg b/apps/web/public/mock/wish/wish-5.jpg new file mode 100644 index 000000000..750fc3cf4 Binary files /dev/null and b/apps/web/public/mock/wish/wish-5.jpg differ diff --git a/apps/web/public/mock/wish/wish-6.jpg b/apps/web/public/mock/wish/wish-6.jpg new file mode 100644 index 000000000..6fc7babc1 Binary files /dev/null and b/apps/web/public/mock/wish/wish-6.jpg differ diff --git a/apps/web/src/app/tournament/[id]/create/by-wish/_components/WishSelectCard.tsx b/apps/web/src/app/tournament/[id]/create/by-wish/_components/WishSelectCard.tsx index 4ef625b8f..38624b71f 100644 --- a/apps/web/src/app/tournament/[id]/create/by-wish/_components/WishSelectCard.tsx +++ b/apps/web/src/app/tournament/[id]/create/by-wish/_components/WishSelectCard.tsx @@ -1,6 +1,7 @@ import { CheckboxEmptyIconFill, CheckboxSelectedIconFill } from '@/assets/icons'; import WishCard from '@/components/common/wish-card'; import { ITEM_STATUS } from '@/consts/item'; +import { Z_INDEX } from '@/consts/zIndex'; type WishSelectCardProps = { name: string | null; @@ -33,7 +34,10 @@ function WishSelectCard({ imageUrl={imageUrl} sourcePlatform={sourcePlatform} /> - + {isSelected ? ( ) : ( diff --git a/apps/web/src/app/tournament/[id]/match/_components/MatchSkeleton.tsx b/apps/web/src/app/tournament/[id]/match/_components/MatchSkeleton.tsx index 50f08fca5..77e7b909d 100644 --- a/apps/web/src/app/tournament/[id]/match/_components/MatchSkeleton.tsx +++ b/apps/web/src/app/tournament/[id]/match/_components/MatchSkeleton.tsx @@ -1,4 +1,5 @@ import Skeleton from '@/components/skeleton'; +import { Z_INDEX } from '@/consts/zIndex'; type MatchSkeletonProps = { isFinal?: boolean; @@ -25,8 +26,8 @@ function MatchSkeleton({ isFinal = false }: MatchSkeletonProps) { ))}
VS
diff --git a/apps/web/src/app/tournament/[id]/match/_components/RoundTransitionSheet.tsx b/apps/web/src/app/tournament/[id]/match/_components/RoundTransitionSheet.tsx index a6cf3d333..3f3672d2c 100644 --- a/apps/web/src/app/tournament/[id]/match/_components/RoundTransitionSheet.tsx +++ b/apps/web/src/app/tournament/[id]/match/_components/RoundTransitionSheet.tsx @@ -3,6 +3,8 @@ import { useCallback, useEffect, useState } from 'react'; import type { CSSProperties } from 'react'; +import { Z_INDEX } from '@/consts/zIndex'; + import type { TransitionStageT } from '../_utils/rounds'; import './RoundTransitionSheet.css'; @@ -316,7 +318,8 @@ function RoundTransitionSheet({ stage, onComplete }: RoundTransitionSheetProps) <> {/* Backdrop */}
@@ -326,8 +329,9 @@ function RoundTransitionSheet({ stage, onComplete }: RoundTransitionSheetProps) role="dialog" aria-modal="true" aria-label={title} - className={`fixed bottom-0 left-1/2 z-50 w-full max-w-120 -translate-x-1/2 cursor-pointer overflow-hidden ${isExiting ? 'rs-sheet-exit' : 'rs-sheet'}`} + className={`fixed bottom-0 left-1/2 w-full max-w-120 -translate-x-1/2 cursor-pointer overflow-hidden ${isExiting ? 'rs-sheet-exit' : 'rs-sheet'}`} style={{ + zIndex: Z_INDEX.DRAWER, height: 540, borderRadius: '24px 24px 0 0', background: 'linear-gradient(180deg, #ECF8FE 0%, #FFF 50%)', diff --git a/apps/web/src/app/tournament/[id]/match/_components/VsSection.tsx b/apps/web/src/app/tournament/[id]/match/_components/VsSection.tsx index df17f9a4a..f50d62204 100644 --- a/apps/web/src/app/tournament/[id]/match/_components/VsSection.tsx +++ b/apps/web/src/app/tournament/[id]/match/_components/VsSection.tsx @@ -1,5 +1,7 @@ 'use client'; +import { Z_INDEX } from '@/consts/zIndex'; + import type { ProductT } from '../../_common/_types/tournament'; import useCardSelectionAnimation from '../_hooks/useCardSelectionAnimation'; import ProductCard from './ProductCard'; @@ -92,8 +94,9 @@ function VsSection({ left, right, isFinal = false, onSelect }: VsSectionProps) { {/* VS 뱃지 */}
{ - const { __NA, _N, __PRIVATE_NEXTJS_INTERNALS_TREE, ...customState } = - (window.history.state as Record | null) ?? {}; - - window.history.replaceState(customState, '', `?tab=${tab}&play=${play}`); + replaceUrlKeepingState(`?tab=${tab}&play=${play}`); }; /** 탭을 옮기면 플레이 유형 필터는 항상 '전체'로 초기화 */ @@ -41,7 +37,10 @@ function TournamentHistorySection() { return ( <> -
+

내 토너먼트

diff --git a/apps/web/src/app/tournament/history/_components/TournamentPlayTypeFilter.tsx b/apps/web/src/app/tournament/history/_components/TournamentPlayTypeFilter.tsx index c07a5e721..82b0b9b57 100644 --- a/apps/web/src/app/tournament/history/_components/TournamentPlayTypeFilter.tsx +++ b/apps/web/src/app/tournament/history/_components/TournamentPlayTypeFilter.tsx @@ -5,7 +5,8 @@ import type { ComponentType, SVGProps } from 'react'; import { GroupIconFill, PersonIconFill } from '@/assets/icons'; import { cn } from '@/utils/cn'; -import { PLAY_TYPE_FILTER, type TournamentPlayTypeFilterT } from '../_consts/tournamentPlayType'; +import { PLAY_TYPE_FILTER } from '../_consts/tournamentPlayType'; +import type { TournamentPlayTypeFilterT } from '../_types/tournamentPlayType'; type Props = { activeFilter: TournamentPlayTypeFilterT; diff --git a/apps/web/src/app/tournament/history/_components/TournamentStatusTab.tsx b/apps/web/src/app/tournament/history/_components/TournamentStatusTab.tsx index 3d1bb52a6..8de4fbf76 100644 --- a/apps/web/src/app/tournament/history/_components/TournamentStatusTab.tsx +++ b/apps/web/src/app/tournament/history/_components/TournamentStatusTab.tsx @@ -5,7 +5,7 @@ import type { ComponentType, SVGProps } from 'react'; import { BasketIconOutline, ReceiptIconOutline } from '@/assets/icons'; import { cn } from '@/utils/cn'; -import type { TournamentStatusTabT } from '../_consts/tournamentTab'; +import type { TournamentStatusTabT } from '../_types/tournamentTab'; type Props = { activeTab: TournamentStatusTabT; diff --git a/apps/web/src/app/tournament/history/_consts/tournamentPlayType.ts b/apps/web/src/app/tournament/history/_consts/tournamentPlayType.ts index c2ac90e9f..8d33a1c76 100644 --- a/apps/web/src/app/tournament/history/_consts/tournamentPlayType.ts +++ b/apps/web/src/app/tournament/history/_consts/tournamentPlayType.ts @@ -1,14 +1,14 @@ import { TOURNAMENT_PLAY_TYPE } from '@/consts/tournament'; import type { TournamentPlayTypeT } from '@/types/tournament'; +import type { TournamentPlayTypeFilterT } from '../_types/tournamentPlayType'; + export const PLAY_TYPE_FILTER = { ALL: 'all', SOLO: 'solo', SOCIAL: 'social', } as const; -export type TournamentPlayTypeFilterT = (typeof PLAY_TYPE_FILTER)[keyof typeof PLAY_TYPE_FILTER]; - /** NOTE: '전체'는 의도적으로 비워둔다 — 조회 시 playType 파라미터 자체가 빠진다. */ export const PLAY_TYPE_BY_FILTER: Partial> = { diff --git a/apps/web/src/app/tournament/history/_consts/tournamentTab.ts b/apps/web/src/app/tournament/history/_consts/tournamentTab.ts index 4aa4f6e24..e51447552 100644 --- a/apps/web/src/app/tournament/history/_consts/tournamentTab.ts +++ b/apps/web/src/app/tournament/history/_consts/tournamentTab.ts @@ -1,7 +1,7 @@ import { TOURNAMENT_STATUS } from '@/consts/tournament'; import type { TournamentStatusT } from '@/types/tournament'; -export type TournamentStatusTabT = 'ongoing' | 'completed'; +import type { TournamentStatusTabT } from '../_types/tournamentTab'; /** * 탭 → 조회할 토너먼트 상태 매핑 diff --git a/apps/web/src/app/tournament/history/_types/tournamentPlayType.ts b/apps/web/src/app/tournament/history/_types/tournamentPlayType.ts new file mode 100644 index 000000000..51cba50ea --- /dev/null +++ b/apps/web/src/app/tournament/history/_types/tournamentPlayType.ts @@ -0,0 +1,3 @@ +import type { PLAY_TYPE_FILTER } from '../_consts/tournamentPlayType'; + +export type TournamentPlayTypeFilterT = (typeof PLAY_TYPE_FILTER)[keyof typeof PLAY_TYPE_FILTER]; diff --git a/apps/web/src/app/tournament/history/_types/tournamentTab.ts b/apps/web/src/app/tournament/history/_types/tournamentTab.ts new file mode 100644 index 000000000..933bc6284 --- /dev/null +++ b/apps/web/src/app/tournament/history/_types/tournamentTab.ts @@ -0,0 +1 @@ +export type TournamentStatusTabT = 'ongoing' | 'completed'; diff --git a/apps/web/src/app/tournament/history/_utils/tournamentSearchParams.ts b/apps/web/src/app/tournament/history/_utils/tournamentSearchParams.ts index 5cae3f88d..ca1620a8c 100644 --- a/apps/web/src/app/tournament/history/_utils/tournamentSearchParams.ts +++ b/apps/web/src/app/tournament/history/_utils/tournamentSearchParams.ts @@ -1,5 +1,6 @@ -import { PLAY_TYPE_FILTER, type TournamentPlayTypeFilterT } from '../_consts/tournamentPlayType'; -import type { TournamentStatusTabT } from '../_consts/tournamentTab'; +import { PLAY_TYPE_FILTER } from '../_consts/tournamentPlayType'; +import type { TournamentPlayTypeFilterT } from '../_types/tournamentPlayType'; +import type { TournamentStatusTabT } from '../_types/tournamentTab'; export const parseTabParam = (tab?: string | null): TournamentStatusTabT => tab === 'completed' ? 'completed' : 'ongoing'; diff --git a/apps/web/src/app/wish/_common/_components/WishCard.tsx b/apps/web/src/app/wish/_common/_components/WishCard.tsx new file mode 100644 index 000000000..ca183896c --- /dev/null +++ b/apps/web/src/app/wish/_common/_components/WishCard.tsx @@ -0,0 +1,68 @@ +import ImageIconOutline from '@/assets/icons/outline/image.svg'; +import BaseImage from '@/components/base-image'; +import Skeleton from '@/components/skeleton'; +import formatPrice from '@/utils/formatPrice'; + +type WishCardProps = { + name: string | null; + price: number | null; + imageUrl: string | null; + sourcePlatform: string | null; + discountRate: number | null; + preload?: boolean; +}; + +const imageFallback = ( +
+ +
+); + +/** 3열 그리드용 위시 카드 — 이미지·상품명·할인율·가격·커머스 칩 */ +function WishCard({ + name, + price, + imageUrl, + sourcePlatform, + discountRate, + preload = false, +}: WishCardProps) { + return ( +
+
+ {imageUrl ? ( + } + errorFallback={imageFallback} + className="object-cover" + /> + ) : ( + imageFallback + )} +
+ +
+
+

+ {name} +

+

+ {discountRate !== null && {discountRate}%} + {formatPrice(String(price))} +

+
+ {sourcePlatform && ( + + {sourcePlatform} + + )} +
+
+ ); +} + +export default WishCard; diff --git a/apps/web/src/app/wish/_common/_components/WishGrid.tsx b/apps/web/src/app/wish/_common/_components/WishGrid.tsx new file mode 100644 index 000000000..ba2515e3a --- /dev/null +++ b/apps/web/src/app/wish/_common/_components/WishGrid.tsx @@ -0,0 +1,31 @@ +import type { WishListItemT } from '../_types/wish'; +import WishCard from './WishCard'; + +type WishGridProps = { + wishes: WishListItemT[]; +}; + +/** 이미지 preload 개수 */ +const PRELOAD_COUNT = 6; + +/** 위시 3열 그리드 **/ +function WishGrid({ wishes }: WishGridProps) { + return ( +
    + {wishes.map(({ wish, item }, index) => ( +
  • + +
  • + ))} +
+ ); +} + +export default WishGrid; diff --git a/apps/web/src/app/wish/_common/_components/WishSearchLink.tsx b/apps/web/src/app/wish/_common/_components/WishSearchLink.tsx new file mode 100644 index 000000000..bb499efc4 --- /dev/null +++ b/apps/web/src/app/wish/_common/_components/WishSearchLink.tsx @@ -0,0 +1,15 @@ +import Link from 'next/link'; + +import { SearchIconOutline } from '@/assets/icons'; +import { ROUTES } from '@/consts/route'; + +function WishSearchLink() { + return ( + // TODO: 경로 변경 필요 + + + + ); +} + +export default WishSearchLink; diff --git a/apps/web/src/app/wish/_common/_consts/wishMock.ts b/apps/web/src/app/wish/_common/_consts/wishMock.ts new file mode 100644 index 000000000..f79356634 --- /dev/null +++ b/apps/web/src/app/wish/_common/_consts/wishMock.ts @@ -0,0 +1,28 @@ +import { ITEM_STATUS } from '@/consts/item'; + +import type { WishFolderT, WishListItemT } from '../_types/wish'; + +// TEMP: API 연동 시 public/mock/wish 이미지와 함께 제거 +const MOCK_IMAGE_URLS = [1, 2, 3, 4, 5, 6].map(index => `/mock/wish/wish-${index}.jpg`); + +export const MOCK_WISHES: WishListItemT[] = Array.from({ length: 12 }, (_, index) => ({ + wish: { id: index + 1, createdAt: '2026-10-14T00:00:00' }, + item: { + id: index + 1, + status: ITEM_STATUS.READY, + name: + index % 3 === 1 ? '상품명입니다.' : '상품명입니다.\n2줄 이상 길어지면 이렇게 말줄임 처리돼요', + price: 100000, + currency: 'KRW', + imageUrl: MOCK_IMAGE_URLS[index % MOCK_IMAGE_URLS.length] ?? null, + sourceUrl: null, + sourcePlatform: 'BRAND', + discountRate: index % 4 === 3 ? null : 99, + }, + refreshNeeded: null, + reused: null, +})); + +export const MOCK_FOLDERS: WishFolderT[] = [ + { id: 1, name: '가방 위시템', wishIds: [4, 1, 2, 3, 5, 6] }, +]; diff --git a/apps/web/src/app/wish/_common/_types/wish.ts b/apps/web/src/app/wish/_common/_types/wish.ts new file mode 100644 index 000000000..7c36300a5 --- /dev/null +++ b/apps/web/src/app/wish/_common/_types/wish.ts @@ -0,0 +1,17 @@ +import type { ItemT } from '@/types/item'; +import type { GetWishlistResponseT } from '@/types/wish'; + +// TODO: API 연동 시 GetWishlistResponseT 로 교체 — discountRate 는 아직 API 에 없음 +export type WishListItemT = Omit & { + item: ItemT & { + /** 할인율(%) — 할인이 없으면 null */ + discountRate: number | null; + }; +}; + +// TEMP: API 연동 시 응답 타입으로 교체 +export type WishFolderT = { + id: number; + name: string; + wishIds: number[]; +}; diff --git a/apps/web/src/app/wish/_components/AllWishTabContent.tsx b/apps/web/src/app/wish/_components/AllWishTabContent.tsx new file mode 100644 index 000000000..92622de36 --- /dev/null +++ b/apps/web/src/app/wish/_components/AllWishTabContent.tsx @@ -0,0 +1,9 @@ +import WishGrid from '../_common/_components/WishGrid'; +import { MOCK_WISHES } from '../_common/_consts/wishMock'; + +function AllWishTabContent() { + /** TODO: 위시 상세 링크 연결 시 saveScrollAnchor·useScrollRestoration(scope: all) 추가 */ + return ; +} + +export default AllWishTabContent; diff --git a/apps/web/src/app/wish/_components/FolderAddCard.tsx b/apps/web/src/app/wish/_components/FolderAddCard.tsx new file mode 100644 index 000000000..6eda7fcab --- /dev/null +++ b/apps/web/src/app/wish/_components/FolderAddCard.tsx @@ -0,0 +1,20 @@ +import { AddIconOutline } from '@/assets/icons'; + +// TODO: 폴더 추가 동작 연결 +function FolderAddCard() { + return ( + + ); +} + +export default FolderAddCard; diff --git a/apps/web/src/app/wish/_components/FolderCard.tsx b/apps/web/src/app/wish/_components/FolderCard.tsx new file mode 100644 index 000000000..0993af7bc --- /dev/null +++ b/apps/web/src/app/wish/_components/FolderCard.tsx @@ -0,0 +1,72 @@ +'use client'; + +import Link from 'next/link'; +import type { MouseEvent } from 'react'; + +import BaseImage from '@/components/base-image'; +import { ROUTES } from '@/consts/route'; +import { cn } from '@/utils/cn'; +import { SCROLL_NAMESPACE, saveScrollAnchor } from '@/utils/scrollRestoration'; + +import { WISH_TAB } from '../_consts/wishTab'; + +type FolderCardProps = { + folderId: number; + name: string; + wishCount: number; + /** 앞에서부터 최대 3장 사용 */ + thumbnailUrls: (string | null)[]; +}; + +function FolderThumbnail({ url, className }: { url: string | null; className?: string }) { + return ( +
+ {url && ( + + )} +
+ ); +} + +function FolderCard({ folderId, name, wishCount, thumbnailUrls }: FolderCardProps) { + const [first = null, second = null, third = null] = thumbnailUrls; + + const handleClick = (event: MouseEvent) => { + if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return; + + saveScrollAnchor( + { namespace: SCROLL_NAMESPACE.WISH, scope: WISH_TAB.FOLDER }, + event.currentTarget, + folderId + ); + }; + + return ( + +
+ + + +
+
+

{name}

+

위시 {wishCount}개

+
+ + ); +} + +export default FolderCard; diff --git a/apps/web/src/app/wish/_components/FolderTabContent.tsx b/apps/web/src/app/wish/_components/FolderTabContent.tsx new file mode 100644 index 000000000..21a5da5e3 --- /dev/null +++ b/apps/web/src/app/wish/_components/FolderTabContent.tsx @@ -0,0 +1,36 @@ +'use client'; + +import { useScrollRestoration } from '@/hooks/useScrollRestoration'; +import { SCROLL_NAMESPACE } from '@/utils/scrollRestoration'; + +import { MOCK_FOLDERS, MOCK_WISHES } from '../_common/_consts/wishMock'; +import { WISH_TAB } from '../_consts/wishTab'; +import FolderAddCard from './FolderAddCard'; +import FolderCard from './FolderCard'; + +const getImageUrl = (wishId: number) => + MOCK_WISHES.find(({ wish }) => wish.id === wishId)?.item.imageUrl ?? null; + +function FolderTabContent() { + useScrollRestoration({ namespace: SCROLL_NAMESPACE.WISH, scope: WISH_TAB.FOLDER }); + + return ( +
    +
  • + +
  • + {MOCK_FOLDERS.map(({ id, name, wishIds }) => ( +
  • + +
  • + ))} +
+ ); +} + +export default FolderTabContent; diff --git a/apps/web/src/app/wish/_components/WishTabBar.tsx b/apps/web/src/app/wish/_components/WishTabBar.tsx new file mode 100644 index 000000000..ed45e32d4 --- /dev/null +++ b/apps/web/src/app/wish/_components/WishTabBar.tsx @@ -0,0 +1,67 @@ +'use client'; + +import { useSearchParams } from 'next/navigation'; +import type { ComponentType, SVGProps } from 'react'; + +import { FolderIconOutline, HeartIconOutline } from '@/assets/icons'; +import { ROUTES } from '@/consts/route'; +import { cn } from '@/utils/cn'; +import { replaceUrlKeepingState } from '@/utils/replaceUrlKeepingState'; + +import { WISH_TAB, WISH_TAB_PANEL_ID } from '../_consts/wishTab'; +import type { WishTabT } from '../_types/wishTab'; +import { parseWishTabParam } from '../_utils/wishSearchParams'; + +const TABS: { + value: WishTabT; + label: string; + Icon: ComponentType>; + path: string; +}[] = [ + { value: WISH_TAB.ALL, label: '전체 위시', Icon: HeartIconOutline, path: ROUTES.WISH }, + { + value: WISH_TAB.FOLDER, + label: '폴더', + Icon: FolderIconOutline, + path: ROUTES.WISHLIST_FOLDER_TAB, + }, +]; + +function WishTabBar() { + const searchParams = useSearchParams(); + const activeTab = parseWishTabParam(searchParams.get('tab')); + + return ( +
+ {TABS.map(({ value, label, Icon, path }) => { + const isActive = activeTab === value; + return ( + + ); + })} +
+ ); +} + +export default WishTabBar; diff --git a/apps/web/src/app/wish/_components/WishTabContent.tsx b/apps/web/src/app/wish/_components/WishTabContent.tsx new file mode 100644 index 000000000..319adae5d --- /dev/null +++ b/apps/web/src/app/wish/_components/WishTabContent.tsx @@ -0,0 +1,49 @@ +'use client'; + +import { useSearchParams } from 'next/navigation'; +import { useEffect, useLayoutEffect, useRef } from 'react'; + +import { getScrollContainer } from '@/utils/scrollRestoration'; + +import { WISH_TAB, WISH_TAB_PANEL_ID } from '../_consts/wishTab'; +import type { WishTabT } from '../_types/wishTab'; +import { parseWishTabParam } from '../_utils/wishSearchParams'; +import AllWishTabContent from './AllWishTabContent'; +import FolderTabContent from './FolderTabContent'; + +function WishTabContent() { + const searchParams = useSearchParams(); + const activeTab = parseWishTabParam(searchParams.get('tab')); + + /** 탭별 스크롤 위치*/ + const scrollTopByTabRef = useRef>>({}); + const activeTabRef = useRef(activeTab); + + useEffect(() => { + const scroller = getScrollContainer(); + if (!scroller) return; + + const handleScroll = () => { + scrollTopByTabRef.current[activeTabRef.current] = scroller.scrollTop; + }; + scroller.addEventListener('scroll', handleScroll, { passive: true }); + return () => scroller.removeEventListener('scroll', handleScroll); + }, []); + + /** 탭별 스크롤 위치 복원. 첫 진입 시에는 생략 */ + useLayoutEffect(() => { + if (activeTabRef.current === activeTab) return; + + activeTabRef.current = activeTab; + const scroller = getScrollContainer(); + if (scroller) scroller.scrollTop = scrollTopByTabRef.current[activeTab] ?? 0; + }, [activeTab]); + + return ( +
+ {activeTab === WISH_TAB.FOLDER ? : } +
+ ); +} + +export default WishTabContent; diff --git a/apps/web/src/app/wish/_components/WishTabSummary.tsx b/apps/web/src/app/wish/_components/WishTabSummary.tsx new file mode 100644 index 000000000..6282ce12a --- /dev/null +++ b/apps/web/src/app/wish/_components/WishTabSummary.tsx @@ -0,0 +1,31 @@ +'use client'; + +import { useSearchParams } from 'next/navigation'; + +import { MOCK_FOLDERS, MOCK_WISHES } from '../_common/_consts/wishMock'; +import { WISH_TAB } from '../_consts/wishTab'; +import { parseWishTabParam } from '../_utils/wishSearchParams'; + +const MAX_DISPLAY_COUNT = 999; + +function WishTabSummary() { + const searchParams = useSearchParams(); + const isFolderTab = parseWishTabParam(searchParams.get('tab')) === WISH_TAB.FOLDER; + + const wishCount = MOCK_WISHES.length; + + return ( +
+

+ {isFolderTab + ? `폴더 ${MOCK_FOLDERS.length}개` + : `상품 ${wishCount > MAX_DISPLAY_COUNT ? `${MAX_DISPLAY_COUNT}+` : wishCount}`} +

+ +
+ ); +} + +export default WishTabSummary; diff --git a/apps/web/src/app/wish/_consts/wishTab.ts b/apps/web/src/app/wish/_consts/wishTab.ts new file mode 100644 index 000000000..0ab0be870 --- /dev/null +++ b/apps/web/src/app/wish/_consts/wishTab.ts @@ -0,0 +1,6 @@ +export const WISH_TAB = { + ALL: 'all', + FOLDER: 'folder', +} as const; + +export const WISH_TAB_PANEL_ID = 'wish-tab-panel'; diff --git a/apps/web/src/app/wish/_types/wishTab.ts b/apps/web/src/app/wish/_types/wishTab.ts new file mode 100644 index 000000000..780caa6f4 --- /dev/null +++ b/apps/web/src/app/wish/_types/wishTab.ts @@ -0,0 +1,3 @@ +import type { WISH_TAB } from '../_consts/wishTab'; + +export type WishTabT = (typeof WISH_TAB)[keyof typeof WISH_TAB]; diff --git a/apps/web/src/app/wish/_utils/wishSearchParams.ts b/apps/web/src/app/wish/_utils/wishSearchParams.ts new file mode 100644 index 000000000..30ff69a10 --- /dev/null +++ b/apps/web/src/app/wish/_utils/wishSearchParams.ts @@ -0,0 +1,5 @@ +import { WISH_TAB } from '../_consts/wishTab'; +import type { WishTabT } from '../_types/wishTab'; + +export const parseWishTabParam = (tab?: string | null): WishTabT => + tab === WISH_TAB.FOLDER ? WISH_TAB.FOLDER : WISH_TAB.ALL; diff --git a/apps/web/src/app/wish/folder/[folderId]/_components/FolderDetailContent.tsx b/apps/web/src/app/wish/folder/[folderId]/_components/FolderDetailContent.tsx new file mode 100644 index 000000000..8f368bbec --- /dev/null +++ b/apps/web/src/app/wish/folder/[folderId]/_components/FolderDetailContent.tsx @@ -0,0 +1,89 @@ +'use client'; + +import { useRouter } from 'next/navigation'; +import { useEffect } from 'react'; +import { toast } from 'sonner'; + +import { ChevronBackwardIconFill } from '@/assets/icons'; +import { Header } from '@/components/header'; +import Spacing from '@/components/spacing'; +import { ROUTES } from '@/consts/route'; +import { Z_INDEX } from '@/consts/zIndex'; +import { useBackWithFallback } from '@/hooks/useBackWithFallback'; + +import WishGrid from '../../../_common/_components/WishGrid'; +import WishSearchLink from '../../../_common/_components/WishSearchLink'; +import { MOCK_FOLDERS, MOCK_WISHES } from '../../../_common/_consts/wishMock'; + +type FolderDetailContentProps = { + folderId: number | null; +}; + +function FolderDetailContent({ folderId }: FolderDetailContentProps) { + const router = useRouter(); + const backWithFallback = useBackWithFallback(); + + const folder = MOCK_FOLDERS.find(({ id }) => id === folderId); + + /** 없는 폴더는 안내 후 폴더 탭으로 — 기록에 남기지 않는다 */ + useEffect(() => { + if (folder) return; + + toast.error('폴더를 찾을 수 없어요.'); + router.replace(ROUTES.WISHLIST_FOLDER_TAB); + }, [folder, router]); + + if (!folder) return null; + + const wishes = MOCK_WISHES.filter(({ wish }) => folder.wishIds.includes(wish.id)); + + return ( + <> +
+
+
backWithFallback(ROUTES.WISHLIST_FOLDER_TAB)} + className="cursor-pointer" + > + + + } + center={ +

+ {folder.name} +

+ } + right={} + /> +
+ +
+

+ {folder.name} + + 전체({wishes.length}) +

+ +
+ +
+ + {/* TODO: 위시 상세 링크 연결 시 saveScrollAnchor·useScrollRestoration(scope: folderId) 추가 */} + + + ); +} + +export default FolderDetailContent; diff --git a/apps/web/src/app/wish/folder/[folderId]/page.tsx b/apps/web/src/app/wish/folder/[folderId]/page.tsx new file mode 100644 index 000000000..cc7c79e5e --- /dev/null +++ b/apps/web/src/app/wish/folder/[folderId]/page.tsx @@ -0,0 +1,19 @@ +import { parseIdParam } from '@/utils/parseIdParam'; + +import FolderDetailContent from './_components/FolderDetailContent'; + +type FolderDetailPageProps = { + params: Promise<{ folderId: string }>; +}; + +async function FolderDetailPage({ params }: FolderDetailPageProps) { + const { folderId } = await params; + + return ( +
+ +
+ ); +} + +export default FolderDetailPage; diff --git a/apps/web/src/app/wish/layout.tsx b/apps/web/src/app/wish/layout.tsx new file mode 100644 index 000000000..9ce73330b --- /dev/null +++ b/apps/web/src/app/wish/layout.tsx @@ -0,0 +1,30 @@ +import { notFound } from 'next/navigation'; + +import LoginRequiredPage from '@/components/common/login-required/LoginRequiredPage'; +import { LOGIN_REQUIRED_TITLE } from '@/components/common/login-required/loginRequired.const'; +import { GUEST_BLOCK_LOCATION } from '@/consts/guestBlockLocation'; +import { ROUTES } from '@/consts/route'; +import { getRoleOrRedirect } from '@/utils/getRoleOrRedirect'; + +type WishLayoutProps = { + children: React.ReactNode; +}; + +async function WishLayout({ children }: WishLayoutProps) { + if (process.env.NEXT_PUBLIC_STAGE === 'production') notFound(); // TEMP + + const role = await getRoleOrRedirect(); + + if (role !== 'MEMBER') + return ( + + ); + + return children; +} + +export default WishLayout; diff --git a/apps/web/src/app/wish/page.tsx b/apps/web/src/app/wish/page.tsx new file mode 100644 index 000000000..b41dd8673 --- /dev/null +++ b/apps/web/src/app/wish/page.tsx @@ -0,0 +1,27 @@ +import { Z_INDEX } from '@/consts/zIndex'; + +import WishSearchLink from './_common/_components/WishSearchLink'; +import WishTabBar from './_components/WishTabBar'; +import WishTabContent from './_components/WishTabContent'; +import WishTabSummary from './_components/WishTabSummary'; + +function WishPage() { + return ( +
+
+
+

내 위시리스트

+ +
+ + +
+ +
+ ); +} + +export default WishPage; diff --git a/apps/web/src/components/bottom-tab-bar/index.tsx b/apps/web/src/components/bottom-tab-bar/index.tsx index 66ee5f896..affd737a7 100644 --- a/apps/web/src/components/bottom-tab-bar/index.tsx +++ b/apps/web/src/components/bottom-tab-bar/index.tsx @@ -42,23 +42,21 @@ const TABS = [ }, ] as const; -/** 탭 너비/간격/컨테이너 패딩 (px) — 인디케이터 위치 계산에 사용 */ const TAB_WIDTH = 72; const TAB_GAP = 8; const BAR_PADDING = 4; const TAB_STEP = TAB_WIDTH + TAB_GAP; -/** 바 안쪽 전체 너비 (px) — 인디케이터 right 값 계산용 */ const BAR_WIDTH = BAR_PADDING * 2 + TABS.length * TAB_WIDTH + (TABS.length - 1) * TAB_GAP; -/** 이 거리(px) 이상 움직이면 탭이 아니라 드래그로 판정 — 손떨림 탭이 드래그로 빠지지 않을 만큼 */ +/** 이 거리(px) 이상 움직여야 드래그로 판정 — 손떨림 방지 */ const DRAG_THRESHOLD = 12; -/** 버블이 유리인 채 착지(0.4s + stagger 0.08s)를 마치고 무광으로 돌아오기까지의 시간 (ms). 라우팅은 즉시 하고, persistent 탭바라 이 연출은 이동 후에도 이어서 재생된다. */ +/** 버블 착지가 끝난 뒤 렌즈를 끄기까지의 시간 (ms) */ const LANDING_MS = 480; -/** 스프링 오버슈트 이징 — 리퀴드 글래스 바운스 (짧은 거리 전용, 오버슈트 36%) */ +/** 스냅용 스프링 이징 — 짧은 거리 전용 (오버슈트 36%) */ const SPRING_EASE = 'linear(0, 0.62 3.7%, 1.03 7.2%, 1.28 11%, 1.36 14%, 1.32 20%, 1.14 29%, 1.02 37%, 0.97 44%, 0.99 62%, 1)'; -/** 프레스 슬라이드 이징 — 여러 칸을 건너도 목표를 크게 지나치지 않는 약한 스프링 (오버슈트 7%) */ +/** 프레스 슬라이드 이징 — 여러 칸을 건너도 크게 지나치지 않게 */ const SLIDE_EASE = 'linear(0, 0.5 7.6%, 0.84 15%, 1.04 24%, 1.07 32%, 1.05 40%, 1.01 54%, 0.99 68%, 1)'; @@ -87,7 +85,10 @@ function BottomTabBar() { const [isPressed, setIsPressed] = useState(false); const [isGrabbing, setIsGrabbing] = useState(false); - const activeIndex = TABS.findIndex(({ href }) => matchesTabPath(pathname, href)); + const isWishFolderRoute = /^\/wish(?:\/folder\/[^/]+)?$/.test(pathname); // TODO: /wish/folder/:folderId 만 해당되도록 조건 변경 필요. 현재는 /wish, /wish/folder/:folderId 둘 다 해당. 변수명은 이관 이후 기준으로 작성 + const activeIndex = TABS.findIndex( + ({ href }) => matchesTabPath(pathname, href) || (isWishFolderRoute && href === ROUTES.WISHLIST) + ); useEffect( () => () => { @@ -96,8 +97,8 @@ function BottomTabBar() { [] ); - // 탭 랜딩 페이지에서만 노출 — 하위 상세/편집 라우트에서는 숨긴다 (기존 동작 유지) - if (!TABS.some(({ href }) => href === pathname)) return null; + // 탭 경로와 정확히 일치할 때만 노출 — 하위 상세/편집 라우트에서는 숨김 + if (!isWishFolderRoute && !TABS.some(({ href }) => href === pathname)) return null; const handlePointerDown = (event: React.PointerEvent) => { if (event.button !== 0 || isGrabbingRef.current) return; @@ -114,16 +115,14 @@ function BottomTabBar() { const pressLeft = indexToLeft(pressIndex); isGrabbingRef.current = true; - // 프레스 즉시 렌즈로 변신 — 유리인 채 이동하고, 착지 후에 무광으로 돌아옴 if (lensOffTimerRef.current) clearTimeout(lensOffTimerRef.current); setIsGrabbing(true); - // 마운트 프리페치가 만료된(세그먼트 캐시 최소 유효시간 30s) 뒤 눌린 경우를 위한 재예열 + // 마운트 시 prefetch 가 만료(30s)된 뒤 눌린 경우 대비 const pressTabForPrefetch = TABS[pressIndex]; if (pressTabForPrefetch && pressIndex !== activeIndex) router.prefetch(pressTabForPrefetch.href); - // 어느 탭을 누르든 버블이 눌린 탭으로 미끄러져 온 뒤 드래그 대상이 됨 const isMovingToRight = pressLeft >= indexToLeft(activeIndex); indicator.style.transitionProperty = 'left, right'; indicator.style.transitionDuration = '0.4s, 0.4s'; @@ -142,7 +141,7 @@ function BottomTabBar() { if (!isDraggedRef.current) { if (Math.abs(e.clientX - event.clientX) < DRAG_THRESHOLD) return; isDraggedRef.current = true; - // 이동 애니메이션 중 드래그가 시작되면 현재 렌더 위치에서 이어받아 점프 방지 + // 슬라이드 도중 드래그가 시작되면 현재 렌더 위치에서 이어받아 점프 방지 anchorLeft = indicator.getBoundingClientRect().left - bar.getBoundingClientRect().left; anchorX = e.clientX; } @@ -158,7 +157,6 @@ function BottomTabBar() { triggerHaptic(HAPTIC_STYLE.SELECTION); } - // 이동 속도만큼 진행 방향으로 늘어나는 스쿼시 + 끌려가듯 기우는 스큐 const velocity = clamp(e.clientX - lastX, -10, 10); const speed = Math.abs(velocity); const bubble = bubbleRef.current; @@ -168,7 +166,6 @@ function BottomTabBar() { bubble.style.scale = `${1.25 + speed * 0.02} ${1.25 - speed * 0.02}`; bubble.style.transform = `skewX(${-velocity * 0.6}deg)`; } - // 반사광은 이동 반대편으로 밀리며 잔상처럼 따라옴 const glint = glintRef.current; if (glint) glint.style.translate = `${-velocity * 1.6}px 0`; lastX = e.clientX; @@ -180,7 +177,6 @@ function BottomTabBar() { window.removeEventListener('pointercancel', handleUp); isGrabbingRef.current = false; setIsPressed(false); - // 렌즈는 유리인 채 착지를 마친 뒤에 무광으로 전환 lensOffTimerRef.current = setTimeout(() => setIsGrabbing(false), LANDING_MS); const bubble = bubbleRef.current; @@ -193,7 +189,7 @@ function BottomTabBar() { const glint = glintRef.current; if (glint) glint.style.translate = ''; - // 시스템이 제스처를 중단(pointercancel)하면 스냅·라우팅 없이 인디케이터를 활성 탭으로 복원 + // pointercancel: 스냅·라우팅 없이 활성 탭으로 복원 if (releaseEvent.type === 'pointercancel') { isDraggedRef.current = false; const activeLeft = indexToLeft(activeIndex); @@ -207,7 +203,7 @@ function BottomTabBar() { } if (!isDraggedRef.current) { - // 탭: 버블은 이미 눌린 탭으로 이동 중 — 즉시 라우팅(persistent 탭바라 착지 연출은 이어서 재생됨), Link 클릭은 차단 + // 단순 탭 — Link 클릭은 onClickCapture 에서 막고 여기서 라우팅 const pressTab = TABS[pressIndex]; if (pressTab && pressIndex !== activeIndex) { isDraggedRef.current = true; @@ -216,7 +212,6 @@ function BottomTabBar() { return; } - // 가까운 탭으로 스냅 — 진행 방향 가장자리가 먼저 늘어나는 액체 스프링 const targetIndex = leftToIndex(left); const targetLeft = indexToLeft(targetIndex); const isSnapRight = targetLeft >= left; @@ -229,9 +224,8 @@ function BottomTabBar() { const targetTab = TABS[targetIndex]; if (targetTab && targetIndex !== activeIndex) { - // 활성 탭에서 시작한 드래그는 프레스 프리패치를 건너뛰므로, 스냅 대상을 push 직전에 프리패치 + // 활성 탭에서 시작한 드래그는 프레스 prefetch 를 건너뛰므로 여기서 보충 router.prefetch(targetTab.href); - // 즉시 라우팅 — persistent 탭바라 스냅 연출은 이동 후에도 이어서 재생됨 router.push(targetTab.href); } }; @@ -254,7 +248,7 @@ function BottomTabBar() { onPointerLeave={() => !isGrabbingRef.current && setIsPressed(false)} onPointerCancel={() => !isGrabbingRef.current && setIsPressed(false)} onClickCapture={e => { - // 드래그·탭 제스처의 내비게이션은 릴리즈에서 처리하므로 Link 클릭은 차단 (제스처당 1회) + // 제스처 내비게이션은 pointerup 에서 처리하므로 Link 클릭은 차단 if (!isDraggedRef.current) return; isDraggedRef.current = false; e.preventDefault(); @@ -266,14 +260,13 @@ function BottomTabBar() { isPressed && 'scale-102' )} > - {/* 활성 탭 인디케이터 — 꾹 잡고 드래그해 다른 탭으로 옮길 수 있는 버블 */} + {/* 활성 탭 인디케이터 — 드래그로 옮길 수 있는 버블 */} {activeIndex >= 0 && (
- {/* 누르면 부풀고 드래그 속도에 따라 진행 방향으로 늘어나는 스쿼시 */}
- {/* 이동 시 반대편으로 밀리는 반사광 */}
)} - {TABS.map(({ label, activeIcon: ActiveIcon, inactiveIcon: InactiveIcon, href }) => { - const isActive = matchesTabPath(pathname, href); + {TABS.map(({ label, activeIcon: ActiveIcon, inactiveIcon: InactiveIcon, href }, index) => { + const isActive = index === activeIndex; const Icon = isActive ? ActiveIcon : InactiveIcon; + const linkHref = isWishFolderRoute && href === ROUTES.WISHLIST ? ROUTES.WISH : href; // TEMP return ( @@ -83,7 +85,7 @@ function TournamentCard({
{showMorePopover && ( -
+
`/play/${sourceTournamentId}`, /** 3. Member Only */ - WISHLIST: '/archive/wish', + WISHLIST: '/archive/wish', // TODO: /wish 경로로 변경 필요 WISH_EDIT: (wishId: number) => `/archive/wish/${wishId}`, + WISH: '/wish', // TEMP + WISHLIST_FOLDER_TAB: '/wish?tab=folder', + WISHLIST_FOLDER: (folderId: number) => `/wish/folder/${folderId}`, NOTIFICATION: '/notification', MYPAGE_EDIT: '/mypage/edit', MYPAGE_WITHDRAW: '/mypage/withdraw', diff --git a/apps/web/src/consts/zIndex.ts b/apps/web/src/consts/zIndex.ts index f22a3de77..2310551a8 100644 --- a/apps/web/src/consts/zIndex.ts +++ b/apps/web/src/consts/zIndex.ts @@ -3,6 +3,9 @@ export const Z_INDEX = { BASE_IMAGE: 10, + /** 상단 고정 헤더 - 위시리스트, 토너먼트 히스토리 헤더 */ + STICKY_HEADER: 12, + /** 게스트 - 로그인 유도 전체 화면 */ LOGIN_REQUIRED_OVERLAY: 15, diff --git a/apps/web/src/utils/getRouteType.test.ts b/apps/web/src/utils/getRouteType.test.ts index a0d737b14..f03ab0b06 100644 --- a/apps/web/src/utils/getRouteType.test.ts +++ b/apps/web/src/utils/getRouteType.test.ts @@ -8,9 +8,13 @@ describe('getRouteType', () => { expect(getRouteType('/home/anything')).toBe('MEMBER_AND_GUEST'); expect(getRouteType('/homework')).toBeNull(); - expect(getRouteType('/archive/wish')).toBe('MEMBER_ONLY'); + expect(getRouteType('/archive/wish')).toBe('MEMBER_ONLY'); // TODO: 위시 경로 이관 시 제거 expect(getRouteType('/archive/wish/3')).toBe('MEMBER_ONLY'); expect(getRouteType('/archive/wishlist')).toBeNull(); + + expect(getRouteType('/wish')).toBe('MEMBER_ONLY'); + expect(getRouteType('/wish/folder/3')).toBe('MEMBER_ONLY'); + expect(getRouteType('/wishlist')).toBeNull(); // 올바르지 않은 경로 테스트 }); it('토너먼트 동적 경로는 id 세그먼트 하나와 정확한 끝만 허용한다', () => { diff --git a/apps/web/src/utils/getRouteType.ts b/apps/web/src/utils/getRouteType.ts index 20cd493df..1f065551e 100644 --- a/apps/web/src/utils/getRouteType.ts +++ b/apps/web/src/utils/getRouteType.ts @@ -41,6 +41,7 @@ const isMemberOnlyRoute = (pathname: string) => { if (pathname === ROUTES.MYPAGE_WITHDRAW) return true; if (pathname === ROUTES.MYPAGE_EDIT) return true; if (matchesPath(pathname, ROUTES.WISHLIST)) return true; + if (matchesPath(pathname, ROUTES.WISH)) return true; // TEMP if (matchesPath(pathname, ROUTES.NOTIFICATION)) return true; if (matchesPath(pathname, ROUTES.TOURNAMENT_HISTORY)) return true; diff --git a/apps/web/src/utils/replaceUrlKeepingState.ts b/apps/web/src/utils/replaceUrlKeepingState.ts new file mode 100644 index 000000000..d6595e9ce --- /dev/null +++ b/apps/web/src/utils/replaceUrlKeepingState.ts @@ -0,0 +1,7 @@ +/** history state 보존하면서 url만 변경 */ +export const replaceUrlKeepingState = (url: string) => { + const { __NA, _N, __PRIVATE_NEXTJS_INTERNALS_TREE, ...customState }: Record = + window.history.state ?? {}; + + window.history.replaceState(customState, '', url); +}; diff --git a/apps/web/src/utils/scrollRestoration.ts b/apps/web/src/utils/scrollRestoration.ts index 0cb5da046..e5ff1389d 100644 --- a/apps/web/src/utils/scrollRestoration.ts +++ b/apps/web/src/utils/scrollRestoration.ts @@ -8,7 +8,8 @@ const HISTORY_STATE_KEY = '__pikiScrollKey'; export const SCROLL_ANCHOR_ID_ATTR = 'data-scroll-anchor-id'; export const SCROLL_NAMESPACE = { - ARCHIVE_WISH: 'archiveWish', + ARCHIVE_WISH: 'archiveWish', // TODO: 제거 필요 + WISH: 'wish', // TODO: WISHLIST: 'wishlist'로 변경 예정 TOURNAMENT_HISTORY: 'tournamentHistory', } as const;