diff --git a/docs/conventions/wds-component-usage.md b/docs/conventions/wds-component-usage.md index 280d4e4..52c0992 100644 --- a/docs/conventions/wds-component-usage.md +++ b/docs/conventions/wds-component-usage.md @@ -262,7 +262,9 @@ Figma 상세는 뒤로가기 버튼이 Hero 이미지 **위에 떠 있는** 오 `useScreenHeader`를 호출하지 않으면 슬롯이 `null`(0px)로 남으므로(`useScreenHeader`가 unmount 시 `setHeader(null)`을 한다), 상세 화면은 훅을 아예 호출하지 않고 Hero 안에 `absolute`로 `TopNavigationButton variant="icon"` + `IconChevronLeft`를 얹는다. 버튼 자체는 WDS를 그대로 쓴다. -`ScreenHeader`에 `overlay` prop을 추가하는 방안도 검토했지만(슬롯을 `absolute inset-x-0 top-0 z-10`으로 띄우면 가능 — `ScreenLayout` 프레임이 `relative`다) 공용 컴포넌트가 다른 화면에 영향을 주는 변경이라 로컬로 뒀다. 같은 오버레이 패턴이 두 번째 화면에 나오면 그때 `ScreenHeader`로 올린다(`component-convention.md` §1의 "두 번째 화면에서 실제로 재사용될 때" 규칙과 같은 기준). +`ScreenHeader`에 `overlay` prop을 추가하는 방안도 검토했지만(슬롯을 `absolute inset-x-0 top-0 z-10`으로 띄우면 가능 — `ScreenLayout` 프레임이 `relative`다) 공용 컴포넌트가 다른 화면에 영향을 주는 변경이라 로컬로 뒀다. + +**후기(공지 상세에 같은 패턴이 실제로 생김)**: 이후 공지 상세에도 같은 오버레이 헤더가 필요해졌는데, `ScreenHeader`가 아니라 `PhotoGallery`(아래 "PhotoGallery" 절 참고) 쪽에 `overlay` prop을 둬서 해결했다 — 뒤로가기 버튼이 이미지 영역에 종속된 오버레이라, `ScreenHeader`(헤더 슬롯 전체를 관장) 대신 사진 갤러리 컴포넌트가 자기 위에 뭘 얹을지를 결정하는 게 더 자연스러웠다. 공지 상세는 사진이 있을 때만 이 오버레이를 쓰고(`useScreenHeader(notice?.hasThumbnail ? null : )`), 사진이 없으면 기존처럼 `ScreenHeader`로 되돌아간다. ## 행사 신청 제출 실패 토스트(`1450:93026`) 구현 중 확정된 매핑 | WDS 컴포넌트 | 코드 export | 확인 내용 | @@ -335,6 +337,15 @@ LottieFiles export 원본(`Loading Content`)에는 문구 2줄도 벡터 도형 - 뒤로가기는 행사 신청 화면과 동일하게 `ScreenHeader variant="normal"`의 `leading`에 `TopNavigationButton`+`IconChevronLeft`를 넣는다("Top Navigation 뒤로가기" 절 참고). 공지 상세는 타이틀이 헤더가 아니라 본문(Title Details)에 있어서 `title` prop은 생략한다. - 이미지 갤러리 배경은 실제 공지 사진 API 전까지 `bg-thumbnail-placeholder`(행사 카드와 동일 토큰)를 그대로 재사용했다. +### PhotoGallery — 공지·행사 상세가 공유하는 사진 갤러리(스와이프+호버 화살표는 WDS에 없는 Stream 자체 구현) + +사진이 여러 장일 때 가로 스크롤 스냅으로 넘기는 갤러리를 `src/components/ui/PhotoGallery.tsx`로 공통화해서 공지 상세·행사 상세가 같이 쓴다(도메인 무관 공용 UI라 `features/`가 아니라 `components/ui/`에 둔다 — `coding-style.md` "폴더 구조" 기준). + +- **WDS로 확인된 부분**: 우하단 카운터는 `PageCounter`(위 표), 화살표 아이콘은 `wds-icon`의 `IconChevronLeft`/`IconChevronRight`. +- **Stream 자체 구현(WDS 대응 없음)**: 가로 스크롤 스냅 트랙(`snap-x snap-mandatory`)과 호버 시에만 뜨는 원형 화살표 ` + )} + {currentPage < photoCount && ( + + )} + + )} + + {showCounter && ( +
+ +
+ )} + + ); +} + +export default PhotoGallery; diff --git a/src/components/ui/ScreenLayout.tsx b/src/components/ui/ScreenLayout.tsx index 0453b1e..f694dc0 100644 --- a/src/components/ui/ScreenLayout.tsx +++ b/src/components/ui/ScreenLayout.tsx @@ -49,8 +49,10 @@ interface ScreenLayoutProps { // 높이는 폰이든 데스크톱이든 뷰포트를 꽉 채운다(h-dvh) — 데스크톱에서 높이를 고정하면 낮은 // 뷰포트에서 화면 아래가 잘리고 Bottom Nav가 밀려난다. 폭만 데스크톱 뷰포트(sm 이상)에서 // 480px로 묶는다 — Figma는 375 기준이지만 데스크톱에서 그대로 쓰면 너무 좁다. 폭에 맞춰 -// 같이 움직여야 하는 고정 px는 FeedbacksQaCard(캐러셀 카드)와 BililgeReturnConfirmModal -// 둘뿐이다. 화면과 같은 배경 위에 서는 컬럼이라 그림자로 경계를 표시한다 +// 같이 움직여야 하는 고정 px는 FeedbacksQaCard(캐러셀 카드), BililgeReturnConfirmModal, +// PhotoGallery(공지·행사 상세 사진 갤러리) 셋이다. PhotoGallery는 고정 높이 대신 +// aspect-square를 쓴다 — 실사진을 담는 영역이라 폭이 넓어져도 정사각 비율이 깨지면 안 된다. +// 화면과 같은 배경 위에 서는 컬럼이라 그림자로 경계를 표시한다 // — App.tsx가 같은 브레이크포인트로 이 컬럼을 가운데 세운다. // 세이프에어리어는 앱 셸이 담당하므로 여기서 env(safe-area-inset-*)를 더하지 않는다(중복 여백이 된다). // 배경도 같은 방식으로 라우트 handle에서 받는다 — 헤더 슬롯까지 이 루트 div가 덮기 때문에, diff --git a/src/features/events/EventsDetailScreen.tsx b/src/features/events/EventsDetailScreen.tsx index 6ebbf7b..4c6670e 100644 --- a/src/features/events/EventsDetailScreen.tsx +++ b/src/features/events/EventsDetailScreen.tsx @@ -2,14 +2,13 @@ import { ActionArea, ActionAreaButton, Divider, - PageCounter, TopNavigationButton, Typography, } from "@wanteddev/wds"; import { IconChevronLeft } from "@wanteddev/wds-icon"; -import { type UIEvent, useMemo, useState } from "react"; import { useNavigate, useParams } from "react-router-dom"; +import PhotoGallery from "@/components/ui/PhotoGallery"; import EventsEmptyState from "@/features/events/components/EventsEmptyState"; import EventsStatusBadge from "@/features/events/components/EventsStatusBadge"; import { EVENTS } from "@/features/events/constants/events"; @@ -26,20 +25,8 @@ import { EVENTS } from "@/features/events/constants/events"; function EventsDetailScreen() { const navigate = useNavigate(); const { eventId } = useParams<{ eventId: string }>(); - const [currentPage, setCurrentPage] = useState(1); const event = EVENTS.find((item) => item.id === eventId); - // 실 이미지 API 전까지는 장수만 알고 URL이 없어서, 슬라이드 key를 미리 만들어 둔다. - // map 콜백의 index를 key로 쓰면 noArrayIndexKey에 걸린다. - const imageKeys = useMemo( - () => - Array.from( - { length: event?.imageCount ?? 0 }, - (_, index) => `${event?.id}-image-${index}`, - ), - [event?.id, event?.imageCount], - ); - if (!event) { return (
@@ -54,16 +41,6 @@ function EventsDetailScreen() { const isOpen = event.status === "open"; const hasMultipleImages = event.imageCount > 1; - // 스크롤 위치로 현재 장을 역산한다. 한 장이 뷰포트 폭을 꽉 채우므로 offsetWidth로 나누면 인덱스가 된다. - // scroll 이벤트마다 setState가 불리지만, 같은 값이면 React가 리렌더를 건너뛴다. - const handleHeroScroll = (scrollEvent: UIEvent) => { - const { scrollLeft, offsetWidth } = scrollEvent.currentTarget; - if (offsetWidth === 0) { - return; - } - setCurrentPage(Math.round(scrollLeft / offsetWidth) + 1); - }; - return ( // Figma 상세 루트 배경은 Background/Normal/Normal(#FFFFFF)이다 — ScreenLayout 기본 // 배경(Background/Normal/Alternative, #F7F7F8)과 다르다. 흰 배경이어야 Action Area의 @@ -75,42 +52,27 @@ function EventsDetailScreen() {
{/* Hero — 실제 행사 이미지 API 전까지 Figma와 같은 단색 placeholder. Figma는 375×375 정사각이라 폭이 유동인 지금 레이아웃에서는 aspect-square로 둔다. - 이미지가 여러 장이면 가로 스크롤 스냅으로 한 장씩 넘긴다(브라우저 기본 스크롤이라 - 터치·트랙패드·키보드가 모두 동작하고, 별도 캐러셀 라이브러리가 필요 없다). */} -
-
- {imageKeys.map((imageKey) => ( -
- ))} -
- - {/* 뒤로가기·카운터는 스크롤되지 않게 스크롤 컨테이너 밖에 절대배치한다 */} -
- navigate(-1)} - variant="icon" - > - - -
- {/* 한 장뿐이면 셀 게 없어서 카운터를 감춘다 */} - {hasMultipleImages && ( -
- + 이미지가 여러 장이면 가로 스크롤 스냅으로 한 장씩 넘긴다(공지 상세와 같은 PhotoGallery). */} + + navigate(-1)} + variant="icon" + > + +
- )} -
+ } + photoCount={event.imageCount} + showCounter={hasMultipleImages} + slideClassName="aspect-square" + />
diff --git a/src/features/notices/NoticesDetailScreen.tsx b/src/features/notices/NoticesDetailScreen.tsx index 4ca8a2c..4d8576a 100644 --- a/src/features/notices/NoticesDetailScreen.tsx +++ b/src/features/notices/NoticesDetailScreen.tsx @@ -1,6 +1,5 @@ import { ContentBadge, - PageCounter, type ThemeColorsToken, TopNavigationButton, Typography, @@ -8,6 +7,7 @@ import { import { IconChevronLeft } from "@wanteddev/wds-icon"; import { useNavigate, useParams } from "react-router-dom"; +import PhotoGallery from "@/components/ui/PhotoGallery"; import ScreenHeader from "@/components/ui/ScreenHeader"; import { useScreenHeader } from "@/components/ui/useScreenHeader"; import { @@ -25,20 +25,25 @@ function NoticesDetailScreen() { const { noticeId } = useParams(); const navigate = useNavigate(); const notice = NOTICES.find((item) => item.id === noticeId); + const photoCount = notice?.photoCount ?? 0; + const backButton = ( + navigate(-1)} + variant="icon" + > + + + ); + + // 사진이 있으면 행사 상세와 같은 방식으로 뒤로가기를 사진 위 오버레이로 그려서 사진이 화면 + // 최상단부터 시작하게 한다(ScreenLayout 헤더 슬롯을 비우면 0px로 접힌다). 사진이 없으면 + // 덮을 이미지가 없어서 기존처럼 흰 배경 헤더를 쓴다. useScreenHeader( - navigate(-1)} - variant="icon" - > - - - } - variant="normal" - />, + notice?.hasThumbnail ? null : ( + + ), ); if (!notice) { @@ -59,13 +64,16 @@ function NoticesDetailScreen() { return (
{notice.hasThumbnail && ( -
- -
+ {backButton}
+ } + photoCount={photoCount} + showCounter + slideClassName="aspect-square" + /> )}