From fc5ba96c50745f623767c3b24e415627e7b36d52 Mon Sep 17 00:00:00 2001 From: gain <163835628+leegain1@users.noreply.github.com> Date: Sun, 27 Sep 2026 19:45:03 +0900 Subject: [PATCH 01/12] =?UTF-8?q?feat:=20=ED=97=A4=EB=8D=94=20=EC=9E=90?= =?UTF-8?q?=EB=A6=AC=EB=A5=BC=20=EC=B1=84=EC=9A=B0=EB=8A=94=20=EA=B3=B5?= =?UTF-8?q?=EC=9A=A9=20=ED=99=94=EB=A9=B4=20=EC=8A=A4=EC=BC=88=EB=A0=88?= =?UTF-8?q?=ED=86=A4=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/ui/ScreenSkeleton.tsx | 111 +++++++++++++++++++++++++++ 1 file changed, 111 insertions(+) create mode 100644 src/components/ui/ScreenSkeleton.tsx diff --git a/src/components/ui/ScreenSkeleton.tsx b/src/components/ui/ScreenSkeleton.tsx new file mode 100644 index 0000000..84cf34c --- /dev/null +++ b/src/components/ui/ScreenSkeleton.tsx @@ -0,0 +1,111 @@ +import { Skeleton } from "@wanteddev/wds"; + +import { useScreenHeader } from "@/components/ui/useScreenHeader"; +import { usePrefersReducedMotion } from "@/hooks/usePrefersReducedMotion"; + +// WDS Skeleton의 깜빡임(2초 pulse)은 JS prop으로만 끌 수 있어서, 스켈레톤마다 이 값으로 넘긴다. +export function useSkeletonAnimation() { + return !usePrefersReducedMotion(); +} + +type ScreenHeaderSkeletonVariant = "display" | "normal"; + +// ScreenHeader와 같은 56px 자리에 그린다. 헤더가 비어 있다가 실제 화면이 들어올 때 본문이 헤더 +// 높이만큼 밀려 내려가지 않도록, 스켈레톤도 헤더 슬롯을 채운다. +// display는 좌측 큰 타이틀, normal(상세 화면)은 좌상단 24px 뒤로가기 버튼 자리다. +function ScreenHeaderSkeleton({ + variant, +}: { + variant: ScreenHeaderSkeletonVariant; +}) { + const animation = useSkeletonAnimation(); + + if (variant === "normal") { + return ( +
+ +
+ ); + } + + return ( +
+ +
+ ); +} + +// 화면 스켈레톤이 헤더 슬롯을 채울 때 쓴다. 실제 화면이 마운트되면 그 화면의 useScreenHeader가 덮어쓴다. +export function useScreenHeaderSkeleton( + variant: ScreenHeaderSkeletonVariant = "display", +) { + useScreenHeader(); +} + +// 헤더 아래 Tool 영역(대여/반납·행사/신청내역 SegmentedControl, size="small" 32px) +export function SegmentedControlSkeleton() { + const animation = useSkeletonAnimation(); + + return ( + + ); +} + +// FilterChipGroup 한 줄. 칩 폭은 실제 라벨 길이 대략값이다. +// 순서가 바뀌지 않는 정적 목록이라 key를 미리 붙여 둔다(index key는 noArrayIndexKey에 걸린다). +const FILTER_CHIPS = [ + { key: "chip-1", width: "48px" }, + { key: "chip-2", width: "72px" }, + { key: "chip-3", width: "72px" }, + { key: "chip-4", width: "60px" }, + { key: "chip-5", width: "72px" }, +]; + +// 목록 스켈레톤의 행 key. 행 수만 필요하고 내용이 없어서 미리 만들어 둔다. +export const SKELETON_ROW_KEYS = ["row-1", "row-2", "row-3", "row-4", "row-5"]; + +export function FilterChipsSkeleton() { + const animation = useSkeletonAnimation(); + + return ( +
+ {FILTER_CHIPS.map(({ key, width }) => ( + + ))} +
+ ); +} + +// 전용 스켈레톤이 없는 화면(홈·신청 흐름·준비 중 화면)의 기본 로딩 화면. 본문 모양은 화면마다 +// 달라서 흉내 내지 않고, 헤더 자리만 채운다. +function ScreenSkeleton() { + useScreenHeaderSkeleton(); + + return null; +} + +export default ScreenSkeleton; From b3ce946b4dfd9ef8caa1d1189d4dd79aa65b97d0 Mon Sep 17 00:00:00 2001 From: gain <163835628+leegain1@users.noreply.github.com> Date: Sun, 27 Sep 2026 19:45:03 +0900 Subject: [PATCH 02/12] =?UTF-8?q?feat:=20=EB=B9=8C=EB=A6=B4=EA=B2=8C=C2=B7?= =?UTF-8?q?=ED=96=89=EC=82=AC=C2=B7=EA=B3=B5=EC=A7=80=20=EB=AA=A9=EB=A1=9D?= =?UTF-8?q?=EA=B3=BC=20=EC=83=81=EC=84=B8=20=ED=99=94=EB=A9=B4=20=EC=8A=A4?= =?UTF-8?q?=EC=BC=88=EB=A0=88=ED=86=A4=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/BililgeListSkeleton.tsx | 68 +++++++++++++++ .../components/EventsDetailSkeleton.tsx | 55 ++++++++++++ .../events/components/EventsListSkeleton.tsx | 84 ++++++++++++++++++ .../components/NoticesDetailSkeleton.tsx | 56 ++++++++++++ .../components/NoticesListSkeleton.tsx | 86 +++++++++++++++++++ 5 files changed, 349 insertions(+) create mode 100644 src/features/bililge/components/BililgeListSkeleton.tsx create mode 100644 src/features/events/components/EventsDetailSkeleton.tsx create mode 100644 src/features/events/components/EventsListSkeleton.tsx create mode 100644 src/features/notices/components/NoticesDetailSkeleton.tsx create mode 100644 src/features/notices/components/NoticesListSkeleton.tsx diff --git a/src/features/bililge/components/BililgeListSkeleton.tsx b/src/features/bililge/components/BililgeListSkeleton.tsx new file mode 100644 index 0000000..22a285c --- /dev/null +++ b/src/features/bililge/components/BililgeListSkeleton.tsx @@ -0,0 +1,68 @@ +import { Skeleton } from "@wanteddev/wds"; + +import { + FilterChipsSkeleton, + SegmentedControlSkeleton, + SKELETON_ROW_KEYS, + useScreenHeaderSkeleton, + useSkeletonAnimation, +} from "@/components/ui/ScreenSkeleton"; + +// BililgeListScreen이 로딩되는 동안의 자리 — 대여/반납 토글, 카테고리 칩, 물품 카드(BililgeItemCard) +// 배치를 그대로 따른다. 카드는 흰 면이라 회색 화면 배경 위에 실제 카드와 같은 흰 박스를 깔아 둔다. +function BililgeListSkeleton() { + const animation = useSkeletonAnimation(); + useScreenHeaderSkeleton(); + + return ( +
+
+ +
+
+ +
+
+ {SKELETON_ROW_KEYS.map((key) => ( +
+ +
+ + +
+ +
+ ))} +
+
+ ); +} + +export default BililgeListSkeleton; diff --git a/src/features/events/components/EventsDetailSkeleton.tsx b/src/features/events/components/EventsDetailSkeleton.tsx new file mode 100644 index 0000000..6938f63 --- /dev/null +++ b/src/features/events/components/EventsDetailSkeleton.tsx @@ -0,0 +1,55 @@ +import { Skeleton } from "@wanteddev/wds"; + +import { + SKELETON_ROW_KEYS, + useSkeletonAnimation, +} from "@/components/ui/ScreenSkeleton"; + +// EventsDetailScreen이 로딩되는 동안의 자리 — 정사각 Hero 이미지, 상태 뱃지·제목, 일시/장소/대상 +// 정보 행을 그대로 따른다. 실제 화면처럼 헤더 없이 Hero가 맨 위에서 시작하므로 헤더 슬롯은 비워 둔다. +function EventsDetailSkeleton() { + const animation = useSkeletonAnimation(); + + return ( +
+ +
+
+ + +
+
+ {SKELETON_ROW_KEYS.slice(0, 3).map((key) => ( + + ))} +
+
+
+ ); +} + +export default EventsDetailSkeleton; diff --git a/src/features/events/components/EventsListSkeleton.tsx b/src/features/events/components/EventsListSkeleton.tsx new file mode 100644 index 0000000..c72b71e --- /dev/null +++ b/src/features/events/components/EventsListSkeleton.tsx @@ -0,0 +1,84 @@ +import { Divider, Skeleton } from "@wanteddev/wds"; +import { Fragment } from "react"; + +import { + FilterChipsSkeleton, + SegmentedControlSkeleton, + SKELETON_ROW_KEYS, + useScreenHeaderSkeleton, + useSkeletonAnimation, +} from "@/components/ui/ScreenSkeleton"; + +// EventsListScreen이 로딩되는 동안의 자리 — 행사/신청내역 토글, 모집 상태 칩, 행사 카드(EventsCard: +// 112px 썸네일 + 뱃지·제목·날짜 + 우하단 버튼) 배치를 그대로 따른다. +function EventsListSkeleton() { + const animation = useSkeletonAnimation(); + useScreenHeaderSkeleton(); + + return ( +
+
+ +
+
+ +
+
+ {SKELETON_ROW_KEYS.map((key, index) => ( + + {index > 0 && ( +
+ +
+ )} +
+ +
+
+ + + +
+
+ +
+
+
+
+ ))} +
+
+ ); +} + +export default EventsListSkeleton; diff --git a/src/features/notices/components/NoticesDetailSkeleton.tsx b/src/features/notices/components/NoticesDetailSkeleton.tsx new file mode 100644 index 0000000..7085200 --- /dev/null +++ b/src/features/notices/components/NoticesDetailSkeleton.tsx @@ -0,0 +1,56 @@ +import { Skeleton } from "@wanteddev/wds"; + +import { + SKELETON_ROW_KEYS, + useScreenHeaderSkeleton, + useSkeletonAnimation, +} from "@/components/ui/ScreenSkeleton"; + +// NoticesDetailScreen이 로딩되는 동안의 자리 — 카테고리 뱃지, 제목, 등록일, 본문 문단을 따른다. +// 사진 유무는 공지마다 달라서 사진 자리는 그리지 않는다. +function NoticesDetailSkeleton() { + const animation = useSkeletonAnimation(); + useScreenHeaderSkeleton("normal"); + + return ( +
+
+ + + +
+
+ {SKELETON_ROW_KEYS.map((key) => ( + + ))} +
+
+ ); +} + +export default NoticesDetailSkeleton; diff --git a/src/features/notices/components/NoticesListSkeleton.tsx b/src/features/notices/components/NoticesListSkeleton.tsx new file mode 100644 index 0000000..3ace513 --- /dev/null +++ b/src/features/notices/components/NoticesListSkeleton.tsx @@ -0,0 +1,86 @@ +import { Divider, Skeleton } from "@wanteddev/wds"; +import { Fragment } from "react"; + +import { + SKELETON_ROW_KEYS, + useScreenHeaderSkeleton, + useSkeletonAnimation, +} from "@/components/ui/ScreenSkeleton"; + +// 사진이 있는 공지만 우측에 56px 썸네일이 붙는다(NoticesCard). 목록이 한 모양으로 반복돼 보이지 않게 +// 실제 목데이터처럼 몇 행에만 둔다. +const ROWS_WITH_THUMBNAIL = new Set(["row-1", "row-2", "row-5"]); + +// NoticesListScreen(게시판 공지 탭)이 로딩되는 동안의 자리 — 카테고리 탭 줄, 제목·날짜 행을 따른다. +function NoticesListSkeleton() { + const animation = useSkeletonAnimation(); + useScreenHeaderSkeleton(); + + return ( +
+
+ + + +
+
+ {SKELETON_ROW_KEYS.map((key, index) => ( + + {index > 0 && ( +
+ +
+ )} +
+
+ + +
+ {ROWS_WITH_THUMBNAIL.has(key) && ( + + )} +
+
+ ))} +
+
+ ); +} + +export default NoticesListSkeleton; From 16a8fffaee2813b8ed17337b9bcd4ca8d8569989 Mon Sep 17 00:00:00 2001 From: gain <163835628+leegain1@users.noreply.github.com> Date: Sun, 27 Sep 2026 19:45:03 +0900 Subject: [PATCH 03/12] =?UTF-8?q?feat:=20=ED=99=94=EB=A9=B4=EC=9D=84=20?= =?UTF-8?q?=EB=9D=BC=EC=9A=B0=ED=8A=B8=EB=B3=84=EB=A1=9C=20=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=EB=B6=84=ED=95=A0=ED=95=98=EA=B3=A0=20=EB=A1=9C?= =?UTF-8?q?=EB=94=A9=20=EC=A4=91=20=EC=8A=A4=EC=BC=88=EB=A0=88=ED=86=A4=20?= =?UTF-8?q?=ED=91=9C=EC=8B=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/conventions/coding-style.md | 1 + src/app/router.tsx | 88 ++++++++++++++++++++++++-------- 2 files changed, 67 insertions(+), 22 deletions(-) diff --git a/docs/conventions/coding-style.md b/docs/conventions/coding-style.md index a859b57..ce384f8 100644 --- a/docs/conventions/coding-style.md +++ b/docs/conventions/coding-style.md @@ -41,6 +41,7 @@ 새 옵션이 필요하면 `src/app/ScreenLayoutRoute.tsx`의 `ScreenRouteHandle`에 필드를 추가하고, 그 값을 `ScreenLayout` prop으로 넘긴다. 현재 필드는 `hasBottomNav`(하단 탭 표시)와 `background`(375×812 프레임 배경 — 헤더 뒤까지 포함이라 화면 본문에서 칠할 수 없다. 신청 완료처럼 Figma가 흰 배경으로 그린 화면만 `"normal"`)다. - `ScreenLayout`은 **라우터를 모르는 prop 기반 컴포넌트**로 유지한다. 라우트 정보(`useMatches`)는 `ScreenLayoutRoute`만 읽는다. +- 화면 컴포넌트는 `lazyScreen(() => import(...), <스켈레톤 />)`으로 라우트마다 코드 분할한다. 화면 JS를 받는 동안 fallback 스켈레톤이 보인다. 전용 스켈레톤은 `features/<기능>/components/<화면>Skeleton.tsx`에 두고 WDS `Skeleton`으로 실제 배치를 따라 그린다(헤더 자리는 `useScreenHeaderSkeleton`). 전용 스켈레톤이 없으면 기본값(헤더 자리만 채우는 `ScreenSkeleton`)을 쓴다. - 라우트가 없는 경로는 레이아웃 안의 `path: "*"` 라우트(`ComingSoonScreen`)가 받는다. 하단 탭이 유지돼서 다른 화면으로 돌아갈 수 있다. 구체적인 경로가 `*`보다 항상 우선하므로 배열 순서는 신경 쓰지 않아도 된다. ## 에러 / 비동기 diff --git a/src/app/router.tsx b/src/app/router.tsx index 6eb52c5..cc87cf1 100644 --- a/src/app/router.tsx +++ b/src/app/router.tsx @@ -1,20 +1,32 @@ +import { type ComponentType, lazy, type ReactNode, Suspense } from "react"; import { createBrowserRouter, type RouteObject } from "react-router-dom"; import App from "@/app/App"; -import ComingSoonScreen from "@/app/ComingSoonScreen"; import ScreenLayoutRoute, { type ScreenRouteHandle, } from "@/app/ScreenLayoutRoute"; -import BililgeListScreen from "@/features/bililge/BililgeListScreen"; -import EventsApplicationClosedScreen from "@/features/events/EventsApplicationClosedScreen"; -import EventsApplicationCompleteScreen from "@/features/events/EventsApplicationCompleteScreen"; -import EventsApplicationScreen from "@/features/events/EventsApplicationScreen"; -import EventsDetailScreen from "@/features/events/EventsDetailScreen"; -import EventsListScreen from "@/features/events/EventsListScreen"; -import FeedbacksListScreen from "@/features/feedbacks/FeedbacksListScreen"; -import HomeScreen from "@/features/home/HomeScreen"; -import NoticesDetailScreen from "@/features/notices/NoticesDetailScreen"; -import NoticesListScreen from "@/features/notices/NoticesListScreen"; +import ScreenSkeleton from "@/components/ui/ScreenSkeleton"; +import BililgeListSkeleton from "@/features/bililge/components/BililgeListSkeleton"; +import EventsDetailSkeleton from "@/features/events/components/EventsDetailSkeleton"; +import EventsListSkeleton from "@/features/events/components/EventsListSkeleton"; +import NoticesDetailSkeleton from "@/features/notices/components/NoticesDetailSkeleton"; +import NoticesListSkeleton from "@/features/notices/components/NoticesListSkeleton"; + +// 화면은 라우트마다 따로 코드 분할한다. 첫 진입에 받는 JS가 줄고, 화면 JS를 받는 동안에는 그 화면 +// 모양의 스켈레톤(fallback)을 보여준다 — 빈 화면이 잠깐 뜨는 대신 곧 나올 배치가 먼저 보인다. +// 스켈레톤은 fallback이라 코드 분할하지 않는다(여기서 바로 import). +// 전용 스켈레톤이 없는 화면은 헤더 자리만 채우는 ScreenSkeleton을 쓴다. +function lazyScreen( + load: () => Promise<{ default: ComponentType }>, + fallback: ReactNode = , +) { + const Screen = lazy(load); + return ( + + + + ); +} // 앱의 모든 라우트는 이 객체 배열 한곳에서 정의한다 — 새 화면은 여기에 라우트를 추가한다. // satisfies로 선언 시점에 RouteObject 형태를 검사한다. @@ -24,40 +36,64 @@ const routes = [ children: [ { children: [ - { element: , path: "/" }, - { element: , path: "/bililge" }, { - element: , + element: lazyScreen(() => import("@/features/home/HomeScreen")), + path: "/", + }, + { + element: lazyScreen( + () => import("@/features/bililge/BililgeListScreen"), + , + ), + path: "/bililge", + }, + { + element: lazyScreen( + () => import("@/features/events/EventsListScreen"), + , + ), // 카드 없이 구분선으로만 나뉘는 목록이라 화면 전체가 흰 면이다 handle: { background: "normal" } satisfies ScreenRouteHandle, path: "/events", }, { - element: , + element: lazyScreen( + () => import("@/features/notices/NoticesListScreen"), + , + ), // 카드 없이 구분선으로만 나뉘는 목록이라 화면 전체가 흰 면이다 handle: { background: "normal" } satisfies ScreenRouteHandle, path: "/notices", }, { - element: , + element: lazyScreen( + () => import("@/features/feedbacks/FeedbacksListScreen"), + ), // 공지 화면과 같은 이유(카드 없이 구분선으로만 나뉘는 목록)로 흰 면을 쓴다 handle: { background: "normal" } satisfies ScreenRouteHandle, path: "/feedbacks", }, { - element: , + element: lazyScreen( + () => import("@/features/events/EventsDetailScreen"), + , + ), // Bottom Nav 대신 하단 고정 버튼(Action Area)이 있는 화면 handle: { hasBottomNav: false } satisfies ScreenRouteHandle, path: "/events/:eventId", }, { - element: , + element: lazyScreen( + () => import("@/features/events/EventsApplicationScreen"), + ), // Bottom Nav 대신 하단 고정 버튼(Action Area)이 있는 화면 handle: { hasBottomNav: false } satisfies ScreenRouteHandle, path: "/events/:eventId/apply", }, { - element: , + element: lazyScreen( + () => import("@/features/events/EventsApplicationCompleteScreen"), + ), // 신청 결과 화면 — 하단 탭 없이 흰 배경 전체 화면이다 handle: { background: "normal", @@ -66,7 +102,9 @@ const routes = [ path: "/events/:eventId/apply/complete", }, { - element: , + element: lazyScreen( + () => import("@/features/events/EventsApplicationClosedScreen"), + ), // 신청 결과 화면 — 하단 탭 없이 흰 배경 전체 화면이다 handle: { background: "normal", @@ -75,7 +113,10 @@ const routes = [ path: "/events/:eventId/apply/closed", }, { - element: , + element: lazyScreen( + () => import("@/features/notices/NoticesDetailScreen"), + , + ), // 상세 화면은 뒤로가기로만 돌아가는 흐름이라 Bottom Nav를 안 보여준다. 카드 없이 // 본문이 배경까지 흰 면이라 목록 화면과 같은 background: "normal"을 쓴다. handle: { @@ -85,7 +126,10 @@ const routes = [ path: "/notices/:noticeId", }, // 라우트가 없는 경로 — 레이아웃 안에 둬서 하단 탭이 유지되고, 탭 경로(/event 등)면 그 탭이 활성으로 보인다 - { element: , path: "*" }, + { + element: lazyScreen(() => import("@/app/ComingSoonScreen")), + path: "*", + }, ], element: , }, From d99cca7e2d3efca4d65dc162107aa5b3d82238fa Mon Sep 17 00:00:00 2001 From: gain <163835628+leegain1@users.noreply.github.com> Date: Sun, 27 Sep 2026 19:40:31 +0900 Subject: [PATCH 04/12] =?UTF-8?q?feat:=20=ED=99=94=EB=A9=B4=20=EC=BB=AC?= =?UTF-8?q?=EB=9F=BC=20=EC=8A=A4=ED=83=9D=20=EC=8A=AC=EB=9D=BC=EC=9D=B4?= =?UTF-8?q?=EB=93=9C=20=EC=A0=84=ED=99=98=20=EC=8A=A4=ED=83=80=EC=9D=BC?= =?UTF-8?q?=EA=B3=BC=20=EC=9D=B4=EB=8F=99=20=EB=B0=A9=ED=96=A5=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/ScreenLayoutRoute.tsx | 13 +++++- src/components/ui/ScreenLayout.tsx | 3 +- src/index.css | 73 ++++++++++++++++++++++++++++++ 3 files changed, 87 insertions(+), 2 deletions(-) diff --git a/src/app/ScreenLayoutRoute.tsx b/src/app/ScreenLayoutRoute.tsx index ef28cfb..5ecfea4 100644 --- a/src/app/ScreenLayoutRoute.tsx +++ b/src/app/ScreenLayoutRoute.tsx @@ -1,4 +1,5 @@ -import { useMatches } from "react-router-dom"; +import { useLayoutEffect } from "react"; +import { useMatches, useNavigationType } from "react-router-dom"; import ScreenLayout, { type ScreenBackground, @@ -36,6 +37,16 @@ function resolveScreenRouteOption( // 읽어 prop으로 넘기기만 한다. 그래서 옵션이 다른 화면이 생겨도 레이아웃 라우트를 따로 선언하지 않고, // 화면을 오가도 레이아웃이 다시 마운트되지 않는다. function ScreenLayoutRoute() { + const navigationType = useNavigationType(); + + // 스택 슬라이드 전환(index.css)의 방향. 뒤로가기(POP)면 반대로 빠진다. + // 전환 애니메이션은 새 화면이 커밋된 뒤 시작되므로, 페인트 전에 도는 layout effect에서 정해 두면 + // 이번 전환부터 바로 반영된다. + useLayoutEffect(() => { + document.documentElement.dataset.navigation = + navigationType === "POP" ? "back" : "forward"; + }, [navigationType]); + const handles = useMatches() .map((match) => match.handle) .filter(isScreenRouteHandle); diff --git a/src/components/ui/ScreenLayout.tsx b/src/components/ui/ScreenLayout.tsx index 3c3d277..84a0c41 100644 --- a/src/components/ui/ScreenLayout.tsx +++ b/src/components/ui/ScreenLayout.tsx @@ -59,6 +59,7 @@ interface ScreenLayoutProps { // 각자 자기 배경을 그 자리까지 연장해야 해서 컴포넌트마다 h-safe-bottom으로 처리한다. // 배경도 같은 방식으로 라우트 handle에서 받는다 — 헤더 슬롯까지 이 루트 div가 덮기 때문에, // 화면이 헤더와 본문에 따로 배경을 깔 필요가 없다. +// 화면 전환(index.css의 스택 슬라이드)은 이 컬럼만 움직인다 — view-transition-name: screen. function ScreenLayout({ hasBottomNav = true, background = "alternative", @@ -75,7 +76,7 @@ function ScreenLayout({
{header}
{/* 스크롤 처리는 각 화면이 스스로 결정한다(예: 상단 토글/필터는 고정하고 목록만 스크롤). diff --git a/src/index.css b/src/index.css index 4dd2296..cd8fb67 100644 --- a/src/index.css +++ b/src/index.css @@ -73,3 +73,76 @@ display: none; } } + +/* 스택 슬라이드 전환 — 목록→상세처럼 viewTransition을 켠 이동만 탄다(react-router가 그 이동의 + 뒤로가기에도 자동으로 켠다). 탭 전환은 옵션을 안 켜서 지금처럼 즉시 바뀐다. + 움직이는 건 화면 컬럼(ScreenLayout, view-transition-name: screen)뿐이고, 데스크톱 양옆 여백인 + root는 그대로 둔다. 방향은 ScreenLayoutRoute가 html[data-navigation]에 적는다. + View Transitions를 지원하지 않는 WebView·브라우저는 react-router가 그냥 즉시 이동한다. */ +@media (prefers-reduced-motion: no-preference) { + ::view-transition-old(root), + ::view-transition-new(root) { + animation: none; + } + + ::view-transition-group(screen) { + animation-duration: 350ms; + animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1); + } + + /* 데스크톱 컬럼(480px) 밖으로 슬라이드가 삐져나오지 않게 자른다 */ + ::view-transition-image-pair(screen) { + overflow: hidden; + } + + ::view-transition-old(screen), + ::view-transition-new(screen) { + animation-duration: inherit; + animation-timing-function: inherit; + /* 기본값(plus-lighter)은 크로스페이드용이라, 겹쳐 미끄러지는 두 화면이 밝게 합쳐진다 */ + mix-blend-mode: normal; + } + + /* 앞으로: 새 화면이 오른쪽에서 덮고, 이전 화면은 왼쪽으로 조금 밀려난다(iOS push와 같은 비율) */ + html[data-navigation="forward"]::view-transition-old(screen) { + animation-name: screen-slide-out-to-left; + } + + html[data-navigation="forward"]::view-transition-new(screen) { + animation-name: screen-slide-in-from-right; + } + + /* 뒤로: 현재 화면이 오른쪽으로 빠지며 아래 화면을 드러낸다 — 빠지는 쪽이 위에 있어야 한다 */ + html[data-navigation="back"]::view-transition-old(screen) { + z-index: 1; + animation-name: screen-slide-out-to-right; + } + + html[data-navigation="back"]::view-transition-new(screen) { + animation-name: screen-slide-in-from-left; + } +} + +@keyframes screen-slide-in-from-right { + from { + transform: translateX(100%); + } +} + +@keyframes screen-slide-out-to-left { + to { + transform: translateX(-30%); + } +} + +@keyframes screen-slide-out-to-right { + to { + transform: translateX(100%); + } +} + +@keyframes screen-slide-in-from-left { + from { + transform: translateX(-30%); + } +} From 0099dc3780d69f5a38d0b51a071e7c717f52b8e7 Mon Sep 17 00:00:00 2001 From: gain <163835628+leegain1@users.noreply.github.com> Date: Sun, 27 Sep 2026 19:40:31 +0900 Subject: [PATCH 05/12] =?UTF-8?q?feat:=20=EB=AA=A9=EB=A1=9D=C2=B7=EC=83=81?= =?UTF-8?q?=EC=84=B8=C2=B7=EC=8B=A0=EC=B2=AD=20=EC=8A=A4=ED=83=9D=20?= =?UTF-8?q?=EC=9D=B4=EB=8F=99=EC=97=90=20=EC=8A=AC=EB=9D=BC=EC=9D=B4?= =?UTF-8?q?=EB=93=9C=20=EC=A0=84=ED=99=98=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/conventions/coding-style.md | 1 + src/features/events/EventsApplicationScreen.tsx | 5 ++++- src/features/events/EventsDetailScreen.tsx | 4 +++- src/features/events/EventsListScreen.tsx | 10 ++++++++-- src/features/notices/NoticesListScreen.tsx | 6 +++++- 5 files changed, 21 insertions(+), 5 deletions(-) diff --git a/docs/conventions/coding-style.md b/docs/conventions/coding-style.md index ce384f8..04ab3a5 100644 --- a/docs/conventions/coding-style.md +++ b/docs/conventions/coding-style.md @@ -42,6 +42,7 @@ 새 옵션이 필요하면 `src/app/ScreenLayoutRoute.tsx`의 `ScreenRouteHandle`에 필드를 추가하고, 그 값을 `ScreenLayout` prop으로 넘긴다. 현재 필드는 `hasBottomNav`(하단 탭 표시)와 `background`(375×812 프레임 배경 — 헤더 뒤까지 포함이라 화면 본문에서 칠할 수 없다. 신청 완료처럼 Figma가 흰 배경으로 그린 화면만 `"normal"`)다. - `ScreenLayout`은 **라우터를 모르는 prop 기반 컴포넌트**로 유지한다. 라우트 정보(`useMatches`)는 `ScreenLayoutRoute`만 읽는다. - 화면 컴포넌트는 `lazyScreen(() => import(...), <스켈레톤 />)`으로 라우트마다 코드 분할한다. 화면 JS를 받는 동안 fallback 스켈레톤이 보인다. 전용 스켈레톤은 `features/<기능>/components/<화면>Skeleton.tsx`에 두고 WDS `Skeleton`으로 실제 배치를 따라 그린다(헤더 자리는 `useScreenHeaderSkeleton`). 전용 스켈레톤이 없으면 기본값(헤더 자리만 채우는 `ScreenSkeleton`)을 쓴다. +- 화면 스택을 쌓는 이동(목록→상세, 상세→신청 등)은 `navigate(to, { viewTransition: true })`·``으로 슬라이드 전환을 켠다. 뒤로가기는 react-router가 그 이동을 기억해 반대 방향으로 자동 적용하므로 `navigate(-1)`은 그대로 둔다. Bottom Nav·상단 탭처럼 형제 화면을 오가는 이동과 홈으로 돌아가는 이동은 켜지 않는다(즉시 전환). 애니메이션은 `index.css`, 방향은 `ScreenLayoutRoute`가 정한다. - 라우트가 없는 경로는 레이아웃 안의 `path: "*"` 라우트(`ComingSoonScreen`)가 받는다. 하단 탭이 유지돼서 다른 화면으로 돌아갈 수 있다. 구체적인 경로가 `*`보다 항상 우선하므로 배열 순서는 신경 쓰지 않아도 된다. ## 에러 / 비동기 diff --git a/src/features/events/EventsApplicationScreen.tsx b/src/features/events/EventsApplicationScreen.tsx index 188aeab..a2c9c1b 100644 --- a/src/features/events/EventsApplicationScreen.tsx +++ b/src/features/events/EventsApplicationScreen.tsx @@ -96,7 +96,10 @@ function EventsApplicationScreen() { } // 제출이 끝난 폼으로는 돌아갈 수 없어야 해서 히스토리를 남기지 않고 바꿔치운다 const resultPath = result === "closed" ? "closed" : "complete"; - navigate(`/events/${eventId}/apply/${resultPath}`, { replace: true }); + navigate(`/events/${eventId}/apply/${resultPath}`, { + replace: true, + viewTransition: true, + }); }; useScreenHeader( diff --git a/src/features/events/EventsDetailScreen.tsx b/src/features/events/EventsDetailScreen.tsx index 4bdaa97..03d9d9b 100644 --- a/src/features/events/EventsDetailScreen.tsx +++ b/src/features/events/EventsDetailScreen.tsx @@ -202,7 +202,9 @@ function EventsDetailScreen() { Figma Main Action은 padding 16px 28px(56px)이라 세로 padding만 sx로 맞춘다. */} navigate(`/events/${event.id}/apply`)} + onClick={() => + navigate(`/events/${event.id}/apply`, { viewTransition: true }) + } sx={{ paddingBlock: "16px" }} > {event.actionLabel} diff --git a/src/features/events/EventsListScreen.tsx b/src/features/events/EventsListScreen.tsx index c9ac9ad..f1be769 100644 --- a/src/features/events/EventsListScreen.tsx +++ b/src/features/events/EventsListScreen.tsx @@ -121,8 +121,14 @@ function EventsListScreen() { navigate(`/events/${event.id}/apply`)} - onSelect={() => navigate(`/events/${event.id}`)} + onApply={() => + navigate(`/events/${event.id}/apply`, { + viewTransition: true, + }) + } + onSelect={() => + navigate(`/events/${event.id}`, { viewTransition: true }) + } status={event.status} statusLabel={event.statusLabel} title={event.title} diff --git a/src/features/notices/NoticesListScreen.tsx b/src/features/notices/NoticesListScreen.tsx index fa2e86f..3d94dc6 100644 --- a/src/features/notices/NoticesListScreen.tsx +++ b/src/features/notices/NoticesListScreen.tsx @@ -81,7 +81,11 @@ function NoticesListScreen() {
)} - + Date: Thu, 1 Oct 2026 16:34:01 +0900 Subject: [PATCH 06/12] =?UTF-8?q?fix:=20=EB=B8=8C=EB=9D=BC=EC=9A=B0?= =?UTF-8?q?=EC=A0=80=20=EC=95=9E=EC=9C=BC=EB=A1=9C=EA=B0=80=EA=B8=B0?= =?UTF-8?q?=EB=A5=BC=20=EB=92=A4=EB=A1=9C=EA=B0=80=EA=B8=B0=20=EC=8A=AC?= =?UTF-8?q?=EB=9D=BC=EC=9D=B4=EB=93=9C=EB=A1=9C=20=EC=B2=98=EB=A6=AC?= =?UTF-8?q?=ED=95=98=EB=8D=98=20=EB=B0=A9=ED=96=A5=20=ED=8C=90=EB=B3=84=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/conventions/coding-style.md | 2 +- src/app/ScreenLayoutRoute.tsx | 37 +++++++++++++++++++++++++++----- 2 files changed, 33 insertions(+), 6 deletions(-) diff --git a/docs/conventions/coding-style.md b/docs/conventions/coding-style.md index 04ab3a5..feefd51 100644 --- a/docs/conventions/coding-style.md +++ b/docs/conventions/coding-style.md @@ -42,7 +42,7 @@ 새 옵션이 필요하면 `src/app/ScreenLayoutRoute.tsx`의 `ScreenRouteHandle`에 필드를 추가하고, 그 값을 `ScreenLayout` prop으로 넘긴다. 현재 필드는 `hasBottomNav`(하단 탭 표시)와 `background`(375×812 프레임 배경 — 헤더 뒤까지 포함이라 화면 본문에서 칠할 수 없다. 신청 완료처럼 Figma가 흰 배경으로 그린 화면만 `"normal"`)다. - `ScreenLayout`은 **라우터를 모르는 prop 기반 컴포넌트**로 유지한다. 라우트 정보(`useMatches`)는 `ScreenLayoutRoute`만 읽는다. - 화면 컴포넌트는 `lazyScreen(() => import(...), <스켈레톤 />)`으로 라우트마다 코드 분할한다. 화면 JS를 받는 동안 fallback 스켈레톤이 보인다. 전용 스켈레톤은 `features/<기능>/components/<화면>Skeleton.tsx`에 두고 WDS `Skeleton`으로 실제 배치를 따라 그린다(헤더 자리는 `useScreenHeaderSkeleton`). 전용 스켈레톤이 없으면 기본값(헤더 자리만 채우는 `ScreenSkeleton`)을 쓴다. -- 화면 스택을 쌓는 이동(목록→상세, 상세→신청 등)은 `navigate(to, { viewTransition: true })`·``으로 슬라이드 전환을 켠다. 뒤로가기는 react-router가 그 이동을 기억해 반대 방향으로 자동 적용하므로 `navigate(-1)`은 그대로 둔다. Bottom Nav·상단 탭처럼 형제 화면을 오가는 이동과 홈으로 돌아가는 이동은 켜지 않는다(즉시 전환). 애니메이션은 `index.css`, 방향은 `ScreenLayoutRoute`가 정한다. +- 화면 스택을 쌓는 이동(목록→상세, 상세→신청 등)은 `navigate(to, { viewTransition: true })`·``으로 슬라이드 전환을 켠다. 뒤로가기는 react-router가 그 이동을 기억해 반대 방향으로 자동 적용하므로 `navigate(-1)`은 그대로 둔다. 브라우저 앞으로가기도 POP이라, `ScreenLayoutRoute`는 히스토리 위치(`history.state.idx`)가 줄어든 POP만 뒤로 방향으로 본다. Bottom Nav·상단 탭처럼 형제 화면을 오가는 이동과 홈으로 돌아가는 이동은 켜지 않는다(즉시 전환). 애니메이션은 `index.css`, 방향은 `ScreenLayoutRoute`가 정한다. - 라우트가 없는 경로는 레이아웃 안의 `path: "*"` 라우트(`ComingSoonScreen`)가 받는다. 하단 탭이 유지돼서 다른 화면으로 돌아갈 수 있다. 구체적인 경로가 `*`보다 항상 우선하므로 배열 순서는 신경 쓰지 않아도 된다. ## 에러 / 비동기 diff --git a/src/app/ScreenLayoutRoute.tsx b/src/app/ScreenLayoutRoute.tsx index 5ecfea4..8266c51 100644 --- a/src/app/ScreenLayoutRoute.tsx +++ b/src/app/ScreenLayoutRoute.tsx @@ -1,5 +1,5 @@ -import { useLayoutEffect } from "react"; -import { useMatches, useNavigationType } from "react-router-dom"; +import { useLayoutEffect, useRef } from "react"; +import { useLocation, useMatches, useNavigationType } from "react-router-dom"; import ScreenLayout, { type ScreenBackground, @@ -33,19 +33,46 @@ function resolveScreenRouteOption( return undefined; } +// createBrowserRouter는 history.state.idx에 히스토리 스택 위치를 적어 둔다(push마다 1씩 증가). +function getHistoryIndex(): number | null { + const state: unknown = window.history.state; + if ( + typeof state === "object" && + state !== null && + "idx" in state && + typeof state.idx === "number" + ) { + return state.idx; + } + return null; +} + // ScreenLayout은 라우터를 모르는 prop 기반 레이아웃으로 두고, 이 컴포넌트가 현재 라우트의 handle을 // 읽어 prop으로 넘기기만 한다. 그래서 옵션이 다른 화면이 생겨도 레이아웃 라우트를 따로 선언하지 않고, // 화면을 오가도 레이아웃이 다시 마운트되지 않는다. function ScreenLayoutRoute() { const navigationType = useNavigationType(); + const location = useLocation(); + const historyIndexRef = useRef(null); - // 스택 슬라이드 전환(index.css)의 방향. 뒤로가기(POP)면 반대로 빠진다. + // 스택 슬라이드 전환(index.css)의 방향. 뒤로가기면 반대로 빠진다. + // POP은 브라우저 뒤로가기·앞으로가기를 구분하지 않아서, 히스토리 위치가 이전보다 작아졌을 때만 뒤로 본다. + // 위치를 알 수 없는 POP은 앱에서 거의 뒤로가기뿐이라 뒤로 처리한다. // 전환 애니메이션은 새 화면이 커밋된 뒤 시작되므로, 페인트 전에 도는 layout effect에서 정해 두면 // 이번 전환부터 바로 반영된다. + // biome-ignore lint/correctness/useExhaustiveDependencies: 이동마다(location.key) 방향을 다시 정한다 useLayoutEffect(() => { + const historyIndex = getHistoryIndex(); + const previousHistoryIndex = historyIndexRef.current; + historyIndexRef.current = historyIndex; + + const isForwardPop = + historyIndex !== null && + previousHistoryIndex !== null && + historyIndex > previousHistoryIndex; document.documentElement.dataset.navigation = - navigationType === "POP" ? "back" : "forward"; - }, [navigationType]); + navigationType === "POP" && !isForwardPop ? "back" : "forward"; + }, [navigationType, location.key]); const handles = useMatches() .map((match) => match.handle) From fd71696a86234e501d104d32d0846be84cf9ef26 Mon Sep 17 00:00:00 2001 From: gain <163835628+leegain1@users.noreply.github.com> Date: Thu, 1 Oct 2026 16:34:01 +0900 Subject: [PATCH 07/12] =?UTF-8?q?fix:=20=EB=8F=99=EC=9E=91=20=EC=A4=84?= =?UTF-8?q?=EC=9D=B4=EA=B8=B0=20=EC=84=A4=EC=A0=95=20=EC=8B=9C=20=ED=99=94?= =?UTF-8?q?=EB=A9=B4=20=EC=A0=84=ED=99=98=20=EC=95=A0=EB=8B=88=EB=A9=94?= =?UTF-8?q?=EC=9D=B4=EC=85=98=20=EB=81=84=EA=B8=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/index.css | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/index.css b/src/index.css index 2395345..a60e643 100644 --- a/src/index.css +++ b/src/index.css @@ -136,6 +136,18 @@ } } +/* 동작 줄이기를 켠 사용자는 슬라이드는 물론 브라우저 기본 크로스페이드도 없이 즉시 바뀐다 */ +@media (prefers-reduced-motion: reduce) { + ::view-transition-group(root), + ::view-transition-old(root), + ::view-transition-new(root), + ::view-transition-group(screen), + ::view-transition-old(screen), + ::view-transition-new(screen) { + animation: none; + } +} + @keyframes screen-slide-in-from-right { from { transform: translateX(100%); From ffd6946ab655b577eced8003769d27150ea7315b Mon Sep 17 00:00:00 2001 From: gain <163835628+leegain1@users.noreply.github.com> Date: Thu, 1 Oct 2026 16:51:07 +0900 Subject: [PATCH 08/12] =?UTF-8?q?chore:=20TanStack=20Query=20=EB=8F=84?= =?UTF-8?q?=EC=9E=85=EA=B3=BC=20=EB=AA=A9=EB=8D=B0=EC=9D=B4=ED=84=B0=20?= =?UTF-8?q?=EC=9D=91=EB=8B=B5=20=ED=97=AC=ED=8D=BC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 1 + pnpm-lock.yaml | 18 ++++++++++++++++++ src/lib/mockResponse.ts | 12 ++++++++++++ src/main.tsx | 7 ++++++- 4 files changed, 37 insertions(+), 1 deletion(-) create mode 100644 src/lib/mockResponse.ts diff --git a/package.json b/package.json index 8f3006b..9c8b93f 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ }, "dependencies": { "@ncdai/react-wheel-picker": "^1.2.3", + "@tanstack/react-query": "^5.104.0", "@wanteddev/wds": "^3.12.0", "@wanteddev/wds-icon": "^3.12.0", "lottie-react": "^3.1.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 80c978e..28b3a9b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,6 +11,9 @@ importers: '@ncdai/react-wheel-picker': specifier: ^1.2.3 version: 1.2.3(react@19.2.8) + '@tanstack/react-query': + specifier: ^5.104.0 + version: 5.104.0(react@19.2.8) '@wanteddev/wds': specifier: ^3.12.0 version: 3.12.0(@emotion/cache@11.14.0)(@emotion/react@11.14.0(@types/react@19.2.18)(react@19.2.8))(@emotion/serialize@1.3.3)(@emotion/utils@1.4.2)(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) @@ -716,6 +719,14 @@ packages: peerDependencies: vite: ^5.2.0 || ^6 || ^7 || ^8 + '@tanstack/query-core@5.104.0': + resolution: {integrity: sha512-JrC2r/JQlXt7khBSdUpsgxNvybzOg+aITa+ARRMlP2AFo93Y8vIqz077rp+e61mJetSZ0T6AJbwW1JHer1vrPQ==} + + '@tanstack/react-query@5.104.0': + resolution: {integrity: sha512-e1TZmDCQnWIfiDVryIHeA6Idj+Lfx1hORLOhXS/l5wcgvtVD4yYqbTDl378sGQKIi2cSgb0TEMC9cKK8GGoJEw==} + peerDependencies: + react: ^18 || ^19 + '@types/node@24.13.3': resolution: {integrity: sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==} @@ -1847,6 +1858,13 @@ snapshots: tailwindcss: 4.3.3 vite: 8.2.2(@types/node@24.13.3)(jiti@2.7.0) + '@tanstack/query-core@5.104.0': {} + + '@tanstack/react-query@5.104.0(react@19.2.8)': + dependencies: + '@tanstack/query-core': 5.104.0 + react: 19.2.8 + '@types/node@24.13.3': dependencies: undici-types: 7.18.2 diff --git a/src/lib/mockResponse.ts b/src/lib/mockResponse.ts new file mode 100644 index 0000000..8ca6783 --- /dev/null +++ b/src/lib/mockResponse.ts @@ -0,0 +1,12 @@ +// 실 API가 붙기 전까지 entities의 API 함수가 목데이터를 서버 응답처럼 비동기로 돌려줄 때 쓴다. +// 개발 서버에서는 화면의 로딩 스켈레톤을 확인할 수 있게 일부러 늦게 응답하고, 배포 빌드에서는 바로 응답한다. +const MOCK_DELAY_MS = 500; + +export function mockResponse(data: T): Promise { + if (!import.meta.env.DEV) { + return Promise.resolve(data); + } + return new Promise((resolve) => { + setTimeout(() => resolve(data), MOCK_DELAY_MS); + }); +} diff --git a/src/main.tsx b/src/main.tsx index 0bbfd0c..ffa91d5 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -1,3 +1,4 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { ThemeProvider } from "@wanteddev/wds"; import { StrictMode } from "react"; import { createRoot } from "react-dom/client"; @@ -6,10 +7,14 @@ import { RouterProvider } from "react-router-dom"; import { router } from "@/app/router"; import "./index.css"; +const queryClient = new QueryClient(); + createRoot(document.getElementById("root")!).render( - + + + , ); From e88f71159c2b48e870ae13c0ae46fc86fa6e25c7 Mon Sep 17 00:00:00 2001 From: gain <163835628+leegain1@users.noreply.github.com> Date: Thu, 1 Oct 2026 16:51:07 +0900 Subject: [PATCH 09/12] =?UTF-8?q?feat:=20=ED=99=94=EB=A9=B4=20=EC=95=88=20?= =?UTF-8?q?Suspense=EB=A1=9C=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20=EB=A1=9C?= =?UTF-8?q?=EB=94=A9=20=EC=8A=A4=EC=BC=88=EB=A0=88=ED=86=A4=20=ED=91=9C?= =?UTF-8?q?=EC=8B=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/router.tsx | 14 +- src/components/ui/ScreenSkeleton.tsx | 47 +---- src/entities/bililge/bililgeApi.ts | 8 + .../bililge/bililgeMock.ts} | 8 +- src/entities/bililge/bililgeQueries.ts | 12 ++ src/entities/bililge/types.ts | 6 + src/entities/events/eventsApi.ts | 13 ++ src/entities/events/eventsMock.ts | 71 +++++++ src/entities/events/eventsQueries.ts | 17 ++ src/entities/events/types.ts | 20 ++ src/entities/notices/noticesApi.ts | 13 ++ .../notices/noticesMock.ts} | 14 +- src/entities/notices/noticesQueries.ts | 17 ++ src/entities/notices/types.ts | 13 ++ src/features/bililge/BililgeListScreen.tsx | 45 ++-- .../bililge/components/BililgeItemList.tsx | 31 +++ .../components/BililgeListSkeleton.tsx | 75 +++---- .../bililge/components/BililgeRentalSheet.tsx | 2 +- src/features/events/EventsDetailScreen.tsx | 188 ++--------------- src/features/events/EventsListScreen.tsx | 93 ++------- src/features/events/components/EventsCard.tsx | 3 +- .../events/components/EventsDetailContent.tsx | 192 ++++++++++++++++++ .../components/EventsDetailSkeleton.tsx | 2 +- src/features/events/components/EventsList.tsx | 91 +++++++++ .../events/components/EventsListSkeleton.tsx | 116 +++++------ .../events/components/EventsStatusBadge.tsx | 2 +- src/features/events/constants/events.ts | 91 --------- src/features/notices/NoticesDetailScreen.tsx | 120 +---------- src/features/notices/NoticesListScreen.tsx | 52 +---- .../components/NoticesDetailContent.tsx | 121 +++++++++++ .../components/NoticesDetailSkeleton.tsx | 2 +- .../notices/components/NoticesList.tsx | 46 +++++ .../components/NoticesListSkeleton.tsx | 102 ++++------ 33 files changed, 876 insertions(+), 771 deletions(-) create mode 100644 src/entities/bililge/bililgeApi.ts rename src/{features/bililge/constants/bililgeItems.ts => entities/bililge/bililgeMock.ts} (96%) create mode 100644 src/entities/bililge/bililgeQueries.ts create mode 100644 src/entities/bililge/types.ts create mode 100644 src/entities/events/eventsApi.ts create mode 100644 src/entities/events/eventsMock.ts create mode 100644 src/entities/events/eventsQueries.ts create mode 100644 src/entities/events/types.ts create mode 100644 src/entities/notices/noticesApi.ts rename src/{features/notices/constants/notices.ts => entities/notices/noticesMock.ts} (86%) create mode 100644 src/entities/notices/noticesQueries.ts create mode 100644 src/entities/notices/types.ts create mode 100644 src/features/bililge/components/BililgeItemList.tsx create mode 100644 src/features/events/components/EventsDetailContent.tsx create mode 100644 src/features/events/components/EventsList.tsx create mode 100644 src/features/notices/components/NoticesDetailContent.tsx create mode 100644 src/features/notices/components/NoticesList.tsx diff --git a/src/app/router.tsx b/src/app/router.tsx index da83592..239e481 100644 --- a/src/app/router.tsx +++ b/src/app/router.tsx @@ -6,16 +6,15 @@ import ScreenLayoutRoute, { type ScreenRouteHandle, } from "@/app/ScreenLayoutRoute"; import ScreenSkeleton from "@/components/ui/ScreenSkeleton"; -import BililgeListSkeleton from "@/features/bililge/components/BililgeListSkeleton"; import EventsDetailSkeleton from "@/features/events/components/EventsDetailSkeleton"; -import EventsListSkeleton from "@/features/events/components/EventsListSkeleton"; import NoticesDetailSkeleton from "@/features/notices/components/NoticesDetailSkeleton"; -import NoticesListSkeleton from "@/features/notices/components/NoticesListSkeleton"; -// 화면은 라우트마다 따로 코드 분할한다. 첫 진입에 받는 JS가 줄고, 화면 JS를 받는 동안에는 그 화면 -// 모양의 스켈레톤(fallback)을 보여준다 — 빈 화면이 잠깐 뜨는 대신 곧 나올 배치가 먼저 보인다. +// 화면은 라우트마다 따로 코드 분할한다. 첫 진입에 받는 JS가 줄고, 화면 JS를 받는 동안에는 +// 헤더 자리만 채우는 ScreenSkeleton(fallback)을 보여준다. 데이터 로딩 스켈레톤은 각 화면이 +// 자기 Suspense로 직접 그린다. +// 상세처럼 화면 전체가 데이터 영역인 화면은 화면 안 스켈레톤을 여기 fallback으로도 넘겨서, +// JS 로딩 → 데이터 로딩이 같은 스켈레톤으로 끊김 없이 이어지게 한다. // 스켈레톤은 fallback이라 코드 분할하지 않는다(여기서 바로 import). -// 전용 스켈레톤이 없는 화면은 헤더 자리만 채우는 ScreenSkeleton을 쓴다. function lazyScreen( load: () => Promise<{ default: ComponentType }>, fallback: ReactNode = , @@ -43,14 +42,12 @@ const routes = [ { element: lazyScreen( () => import("@/features/bililge/BililgeListScreen"), - , ), path: "/bililge", }, { element: lazyScreen( () => import("@/features/events/EventsListScreen"), - , ), // 카드 없이 구분선으로만 나뉘는 목록이라 화면 전체가 흰 면이다 handle: { background: "normal" } satisfies ScreenRouteHandle, @@ -59,7 +56,6 @@ const routes = [ { element: lazyScreen( () => import("@/features/notices/NoticesListScreen"), - , ), // 카드 없이 구분선으로만 나뉘는 목록이라 화면 전체가 흰 면이다 handle: { background: "normal" } satisfies ScreenRouteHandle, diff --git a/src/components/ui/ScreenSkeleton.tsx b/src/components/ui/ScreenSkeleton.tsx index 84cf34c..26a5d72 100644 --- a/src/components/ui/ScreenSkeleton.tsx +++ b/src/components/ui/ScreenSkeleton.tsx @@ -54,54 +54,11 @@ export function useScreenHeaderSkeleton( useScreenHeader(); } -// 헤더 아래 Tool 영역(대여/반납·행사/신청내역 SegmentedControl, size="small" 32px) -export function SegmentedControlSkeleton() { - const animation = useSkeletonAnimation(); - - return ( - - ); -} - -// FilterChipGroup 한 줄. 칩 폭은 실제 라벨 길이 대략값이다. -// 순서가 바뀌지 않는 정적 목록이라 key를 미리 붙여 둔다(index key는 noArrayIndexKey에 걸린다). -const FILTER_CHIPS = [ - { key: "chip-1", width: "48px" }, - { key: "chip-2", width: "72px" }, - { key: "chip-3", width: "72px" }, - { key: "chip-4", width: "60px" }, - { key: "chip-5", width: "72px" }, -]; - // 목록 스켈레톤의 행 key. 행 수만 필요하고 내용이 없어서 미리 만들어 둔다. export const SKELETON_ROW_KEYS = ["row-1", "row-2", "row-3", "row-4", "row-5"]; -export function FilterChipsSkeleton() { - const animation = useSkeletonAnimation(); - - return ( -
- {FILTER_CHIPS.map(({ key, width }) => ( - - ))} -
- ); -} - -// 전용 스켈레톤이 없는 화면(홈·신청 흐름·준비 중 화면)의 기본 로딩 화면. 본문 모양은 화면마다 -// 달라서 흉내 내지 않고, 헤더 자리만 채운다. +// 화면 JS를 받는 동안의 기본 로딩 화면(router.tsx의 lazyScreen). 본문 모양은 화면마다 달라서 +// 흉내 내지 않고, 헤더 자리만 채운다 — 데이터 영역의 스켈레톤은 화면이 자기 Suspense로 그린다. function ScreenSkeleton() { useScreenHeaderSkeleton(); diff --git a/src/entities/bililge/bililgeApi.ts b/src/entities/bililge/bililgeApi.ts new file mode 100644 index 0000000..fa32657 --- /dev/null +++ b/src/entities/bililge/bililgeApi.ts @@ -0,0 +1,8 @@ +import { BILILGE_ITEMS } from "@/entities/bililge/bililgeMock"; +import type { BililgeItem } from "@/entities/bililge/types"; +import { mockResponse } from "@/lib/mockResponse"; + +// 실 API가 붙으면 함수 안쪽만 요청 코드로 바꾼다 — 화면은 bililgeQueries로만 데이터를 받는다. +export function fetchBililgeItems(): Promise { + return mockResponse(BILILGE_ITEMS); +} diff --git a/src/features/bililge/constants/bililgeItems.ts b/src/entities/bililge/bililgeMock.ts similarity index 96% rename from src/features/bililge/constants/bililgeItems.ts rename to src/entities/bililge/bililgeMock.ts index 4f2f477..537161a 100644 --- a/src/features/bililge/constants/bililgeItems.ts +++ b/src/entities/bililge/bililgeMock.ts @@ -13,13 +13,7 @@ import powerBank from "@/assets/icons/bililge-items/power-bank.svg"; import sanitaryPad from "@/assets/icons/bililge-items/sanitary-pad.svg"; import umbrella from "@/assets/icons/bililge-items/umbrella.svg"; import usbCCharger from "@/assets/icons/bililge-items/usb-c-charger.svg"; - -export interface BililgeItem { - id: string; - name: string; - quantity: number; - icon: string; -} +import type { BililgeItem } from "@/entities/bililge/types"; // Figma: 빌릴게 Item Grid (nodeId 1243:73343) 순서·물품명·수량을 그대로 옮긴 목데이터 — 실 API 연동 전까지 사용 export const BILILGE_ITEMS: BililgeItem[] = [ diff --git a/src/entities/bililge/bililgeQueries.ts b/src/entities/bililge/bililgeQueries.ts new file mode 100644 index 0000000..2078511 --- /dev/null +++ b/src/entities/bililge/bililgeQueries.ts @@ -0,0 +1,12 @@ +import { queryOptions } from "@tanstack/react-query"; + +import { fetchBililgeItems } from "@/entities/bililge/bililgeApi"; + +// 쿼리 키와 요청 함수를 한곳에 묶어 둔다. 화면은 useSuspenseQuery(bililgeQueries.items())처럼 쓴다. +export const bililgeQueries = { + items: () => + queryOptions({ + queryFn: fetchBililgeItems, + queryKey: ["bililge", "items"], + }), +}; diff --git a/src/entities/bililge/types.ts b/src/entities/bililge/types.ts new file mode 100644 index 0000000..c02d71d --- /dev/null +++ b/src/entities/bililge/types.ts @@ -0,0 +1,6 @@ +export interface BililgeItem { + id: string; + name: string; + quantity: number; + icon: string; +} diff --git a/src/entities/events/eventsApi.ts b/src/entities/events/eventsApi.ts new file mode 100644 index 0000000..ec392ea --- /dev/null +++ b/src/entities/events/eventsApi.ts @@ -0,0 +1,13 @@ +import { EVENTS } from "@/entities/events/eventsMock"; +import type { EventItem } from "@/entities/events/types"; +import { mockResponse } from "@/lib/mockResponse"; + +// 실 API가 붙으면 함수 안쪽만 요청 코드로 바꾼다 — 화면은 eventsQueries로만 데이터를 받는다. +export function fetchEvents(): Promise { + return mockResponse(EVENTS); +} + +// 없는 행사는 null — 상세 화면이 "행사를 찾을 수 없어요" 빈 상태를 그린다. +export function fetchEvent(eventId: string): Promise { + return mockResponse(EVENTS.find((event) => event.id === eventId) ?? null); +} diff --git a/src/entities/events/eventsMock.ts b/src/entities/events/eventsMock.ts new file mode 100644 index 0000000..653f22c --- /dev/null +++ b/src/entities/events/eventsMock.ts @@ -0,0 +1,71 @@ +import type { EventItem } from "@/entities/events/types"; + +// Figma 상세(1133:42433 모집중 / 1156:53992 모집예정)의 메타데이터·본문은 목업이라 행사별로 다르지 않다. +// 목록 카드가 같은 행사명 4개를 상태만 바꿔 보여주는 것과 같은 이유로, 상세 내용도 공통 상수로 두고 +// 상태별로 갈리는 값(뱃지 문구, CTA 문구)만 항목마다 다르게 준다. +const MOCK_SCHEDULE = "4월 15일 (수) 18:30 ~"; +const MOCK_LOCATION = "미래관 4층 신관 입구 (예대 방면)"; +const MOCK_AUDIENCE = ["소프트웨어융합대학 과학생회비 납부자", "선착순 150명"]; +const MOCK_DESCRIPTION = `안녕하십니까, 제10대 소프트웨어융합대학 학생회 ‘에코’입니다. + +기말고사를 준비하고 계신 학우 여러분을 응원하기 위해 간식행사를 진행합니다 🍱✨ + +시험기간 동안 든든하게 힘내시길 바라며, 많은 관심과 참여 부탁드립니다! + +📌 간식행사 일정 +▪️ 일시 : 6월 1일 (월) 11:00 ~ +▪️ 장소 : 미래관 4층 신관 입구 (예대방면) + +📌 대상 +▪️ 소프트웨어융합대학 재학생 선착순 180명 +※ 과학생회비 미납부자 참여 가능 + +📌 간식행사 메뉴 +▪️ 돈까스 도련님 도시락 +▪️ 나랑드사이다 제로 + +📌 유의 사항 +▪️ 소프트웨어융합대학 학생임을 증명할 수 있는 모바일 학생증 혹은 실물 학생증을 지참해주시기 바랍니다. +▪️ 1인당 1세트만 수령 가능하며, 선착순 수량 소진 시 수령이 불가능합니다. + +많은 학우 여러분의 관심과 참여 부탁드립니다. +감사합니다 😊`; + +// Figma: 행사 Event List (nodeId 1243:70866) 문구를 그대로 옮긴 목데이터 — 실 API 연동 전까지 사용. +// +// Figma 목업은 같은 행사명 4개(모집중 1 / 모집예정 1 / 모집종료 2)를 상태만 바꿔 보여주는데, +// 여기서는 모집중을 비우고 모집종료 중복도 하나로 줄였다(모집예정 1 / 모집종료 1). +// +// Empty State(1165:62713)는 Figma가 "모집중" 필터 버전으로만 그려져 있고, 일러스트·문구와 +// "아카이빙 둘러보기" 버튼이 그 조합의 스펙이다. 목데이터에 모집중 항목이 있으면 이 화면을 +// 아예 볼 수 없어서 모집중을 비웠다. 대신 모집중 상세(1133:42433)는 카드로 진입할 수 없다 — +// 실 API가 붙으면 사라질 제약이고, 지금 확인이 필요하면 아래 항목 하나의 status를 "open"으로 +// 되돌리면 된다. 모집예정은 상세 디자인(1156:53992)이 있어 진입 가능하게 남겼다. +export const EVENTS: EventItem[] = [ + { + actionLabel: "모집종료", + audience: MOCK_AUDIENCE, + description: MOCK_DESCRIPTION, + eventDate: "행사일 2026.06.04", + id: "sw-sports-day-closed", + imageCount: 7, + location: MOCK_LOCATION, + schedule: MOCK_SCHEDULE, + status: "closed", + statusLabel: "모집종료", + title: "소프트웨어융합대학 체육대회", + }, + { + actionLabel: "8월 10일 오픈", + audience: MOCK_AUDIENCE, + description: MOCK_DESCRIPTION, + eventDate: "행사일 2026.06.04", + id: "sw-sports-day-upcoming", + imageCount: 7, + location: MOCK_LOCATION, + schedule: MOCK_SCHEDULE, + status: "upcoming", + statusLabel: "모집예정", + title: "소프트웨어융합대학 체육대회", + }, +]; diff --git a/src/entities/events/eventsQueries.ts b/src/entities/events/eventsQueries.ts new file mode 100644 index 0000000..34afe36 --- /dev/null +++ b/src/entities/events/eventsQueries.ts @@ -0,0 +1,17 @@ +import { queryOptions } from "@tanstack/react-query"; + +import { fetchEvent, fetchEvents } from "@/entities/events/eventsApi"; + +// 쿼리 키와 요청 함수를 한곳에 묶어 둔다. 화면은 useSuspenseQuery(eventsQueries.list())처럼 쓴다. +export const eventsQueries = { + detail: (eventId: string) => + queryOptions({ + queryFn: () => fetchEvent(eventId), + queryKey: ["events", eventId], + }), + list: () => + queryOptions({ + queryFn: fetchEvents, + queryKey: ["events"], + }), +}; diff --git a/src/entities/events/types.ts b/src/entities/events/types.ts new file mode 100644 index 0000000..140d997 --- /dev/null +++ b/src/entities/events/types.ts @@ -0,0 +1,20 @@ +export type EventStatus = "open" | "upcoming" | "closed"; + +export interface EventItem { + id: string; + title: string; + eventDate: string; + status: EventStatus; + statusLabel: string; + actionLabel: string; + /** 상세 상단 Hero 이미지 개수 — 실 이미지 API 전까지 PageCounter 표기용 */ + imageCount: number; + /** 상세 메타데이터 "일시" */ + schedule: string; + /** 상세 메타데이터 "장소" */ + location: string; + /** 상세 메타데이터 "대상" — Figma가 두 줄로 쪼개 보여줘서 줄 단위로 들고 있는다 */ + audience: string[]; + /** 상세 본문. 줄바꿈을 그대로 살려 렌더링한다 */ + description: string; +} diff --git a/src/entities/notices/noticesApi.ts b/src/entities/notices/noticesApi.ts new file mode 100644 index 0000000..ec4f283 --- /dev/null +++ b/src/entities/notices/noticesApi.ts @@ -0,0 +1,13 @@ +import { NOTICES } from "@/entities/notices/noticesMock"; +import type { Notice } from "@/entities/notices/types"; +import { mockResponse } from "@/lib/mockResponse"; + +// 실 API가 붙으면 함수 안쪽만 요청 코드로 바꾼다 — 화면은 noticesQueries로만 데이터를 받는다. +export function fetchNotices(): Promise { + return mockResponse(NOTICES); +} + +// 없는 공지는 null — 상세 화면이 "존재하지 않는 공지예요"를 그린다. +export function fetchNotice(noticeId: string): Promise { + return mockResponse(NOTICES.find((notice) => notice.id === noticeId) ?? null); +} diff --git a/src/features/notices/constants/notices.ts b/src/entities/notices/noticesMock.ts similarity index 86% rename from src/features/notices/constants/notices.ts rename to src/entities/notices/noticesMock.ts index fc61f53..fa1ce56 100644 --- a/src/features/notices/constants/notices.ts +++ b/src/entities/notices/noticesMock.ts @@ -1,16 +1,4 @@ -export type NoticeCategory = "일반" | "제휴"; - -export interface Notice { - id: string; - title: string; - date: string; - category: NoticeCategory; - isPinned?: boolean; - hasThumbnail?: boolean; - /** 상세 화면 이미지 갤러리 총 장수 — hasThumbnail일 때만 의미가 있다. */ - photoCount?: number; - body: string; -} +import type { Notice } from "@/entities/notices/types"; // Figma: 공지 상세 (nodeId 1256:81842) 목업 본문 — 목업 데이터라 공지 5건이 본문을 공유한다. const NOTICE_BODY = `안녕하십니까, 제10대 소프트웨어융합대학 학생회 '에코'입니다. diff --git a/src/entities/notices/noticesQueries.ts b/src/entities/notices/noticesQueries.ts new file mode 100644 index 0000000..df6611e --- /dev/null +++ b/src/entities/notices/noticesQueries.ts @@ -0,0 +1,17 @@ +import { queryOptions } from "@tanstack/react-query"; + +import { fetchNotice, fetchNotices } from "@/entities/notices/noticesApi"; + +// 쿼리 키와 요청 함수를 한곳에 묶어 둔다. 화면은 useSuspenseQuery(noticesQueries.list())처럼 쓴다. +export const noticesQueries = { + detail: (noticeId: string) => + queryOptions({ + queryFn: () => fetchNotice(noticeId), + queryKey: ["notices", noticeId], + }), + list: () => + queryOptions({ + queryFn: fetchNotices, + queryKey: ["notices"], + }), +}; diff --git a/src/entities/notices/types.ts b/src/entities/notices/types.ts new file mode 100644 index 0000000..4419ef1 --- /dev/null +++ b/src/entities/notices/types.ts @@ -0,0 +1,13 @@ +export type NoticeCategory = "일반" | "제휴"; + +export interface Notice { + id: string; + title: string; + date: string; + category: NoticeCategory; + isPinned?: boolean; + hasThumbnail?: boolean; + /** 상세 화면 이미지 갤러리 총 장수 — hasThumbnail일 때만 의미가 있다. */ + photoCount?: number; + body: string; +} diff --git a/src/features/bililge/BililgeListScreen.tsx b/src/features/bililge/BililgeListScreen.tsx index 9408187..527d90e 100644 --- a/src/features/bililge/BililgeListScreen.tsx +++ b/src/features/bililge/BililgeListScreen.tsx @@ -4,18 +4,16 @@ import { TopNavigationButton, } from "@wanteddev/wds"; import { IconBell, IconSearch } from "@wanteddev/wds-icon"; -import { startTransition, useState } from "react"; +import { Suspense, startTransition, useState } from "react"; import ScreenHeader from "@/components/ui/ScreenHeader"; import { useScreenHeader } from "@/components/ui/useScreenHeader"; +import type { BililgeItem } from "@/entities/bililge/types"; import BililgeCategoryFilter from "@/features/bililge/components/BililgeCategoryFilter"; -import BililgeItemCard from "@/features/bililge/components/BililgeItemCard"; +import BililgeItemList from "@/features/bililge/components/BililgeItemList"; +import BililgeListSkeleton from "@/features/bililge/components/BililgeListSkeleton"; import BililgeRentalSheet from "@/features/bililge/components/BililgeRentalSheet"; import BililgeReturnSection from "@/features/bililge/components/BililgeReturnSection"; -import { - BILILGE_ITEMS, - type BililgeItem, -} from "@/features/bililge/constants/bililgeItems"; // Figma: 빌릴게 (nodeId 1243:73331) function BililgeListScreen() { @@ -62,26 +60,21 @@ function BililgeListScreen() {
{tab === "rent" ? ( -
- {BILILGE_ITEMS.map((item) => ( - { - // 바텀시트를 여는 것(슬라이드 애니메이션)은 즉시 반영하고, 그 안의 휠 - // 피커(특히 분 60개) 마운트처럼 무거운 작업은 startTransition으로 낮은 - // 우선순위로 미뤄서 첫 프레임이 버벅이지 않게 한다 — 처음 열 때만 해당하고, - // rentalItem은 닫아도 null로 안 돌아가서 두 번째부터는 이 마운트 비용 자체가 없다. - setRentalSheetOpen(true); - startTransition(() => { - setRentalItem(item); - }); - }} - subtitle={`수량 ${item.quantity}`} - /> - ))} -
+ // 헤더·토글·필터는 데이터와 무관해서 바로 그리고, 데이터를 받는 목록 자리만 스켈레톤으로 채운다 + }> + { + // 바텀시트를 여는 것(슬라이드 애니메이션)은 즉시 반영하고, 그 안의 휠 + // 피커(특히 분 60개) 마운트처럼 무거운 작업은 startTransition으로 낮은 + // 우선순위로 미뤄서 첫 프레임이 버벅이지 않게 한다 — 처음 열 때만 해당하고, + // rentalItem은 닫아도 null로 안 돌아가서 두 번째부터는 이 마운트 비용 자체가 없다. + setRentalSheetOpen(true); + startTransition(() => { + setRentalItem(item); + }); + }} + /> + ) : ( setTab("rent")} /> )} diff --git a/src/features/bililge/components/BililgeItemList.tsx b/src/features/bililge/components/BililgeItemList.tsx new file mode 100644 index 0000000..1ba1d20 --- /dev/null +++ b/src/features/bililge/components/BililgeItemList.tsx @@ -0,0 +1,31 @@ +import { useSuspenseQuery } from "@tanstack/react-query"; + +import { bililgeQueries } from "@/entities/bililge/bililgeQueries"; +import type { BililgeItem } from "@/entities/bililge/types"; +import BililgeItemCard from "@/features/bililge/components/BililgeItemCard"; + +interface BililgeItemListProps { + onRentRequest: (item: BililgeItem) => void; +} + +// 대여 탭의 물품 목록 데이터를 받아 그리는 부분. 데이터를 받는 동안은 BililgeListScreen의 +// Suspense가 BililgeListSkeleton을 보여준다. 대여 시트를 여는 동작은 Screen이 콜백으로 넘긴다. +function BililgeItemList({ onRentRequest }: BililgeItemListProps) { + const { data: items } = useSuspenseQuery(bililgeQueries.items()); + + return ( +
+ {items.map((item) => ( + onRentRequest(item)} + subtitle={`수량 ${item.quantity}`} + /> + ))} +
+ ); +} + +export default BililgeItemList; diff --git a/src/features/bililge/components/BililgeListSkeleton.tsx b/src/features/bililge/components/BililgeListSkeleton.tsx index 22a285c..7d864e8 100644 --- a/src/features/bililge/components/BililgeListSkeleton.tsx +++ b/src/features/bililge/components/BililgeListSkeleton.tsx @@ -1,66 +1,55 @@ import { Skeleton } from "@wanteddev/wds"; import { - FilterChipsSkeleton, - SegmentedControlSkeleton, SKELETON_ROW_KEYS, - useScreenHeaderSkeleton, useSkeletonAnimation, } from "@/components/ui/ScreenSkeleton"; -// BililgeListScreen이 로딩되는 동안의 자리 — 대여/반납 토글, 카테고리 칩, 물품 카드(BililgeItemCard) -// 배치를 그대로 따른다. 카드는 흰 면이라 회색 화면 배경 위에 실제 카드와 같은 흰 박스를 깔아 둔다. +// 대여 물품 데이터를 받는 동안 BililgeListScreen의 목록 자리에 보이는 스켈레톤 — 헤더·토글·필터는 +// 화면이 바로 그리므로 물품 카드(BililgeItemCard) 배치만 따른다. 카드는 흰 면이라 회색 화면 배경 위에 +// 실제 카드와 같은 흰 박스를 깔아 둔다. function BililgeListSkeleton() { const animation = useSkeletonAnimation(); - useScreenHeaderSkeleton(); return ( -
-
- -
-
- -
-
- {SKELETON_ROW_KEYS.map((key) => ( -
+
+ {SKELETON_ROW_KEYS.map((key) => ( +
+ +
-
- - -
- ))} -
+ +
+ ))}
); } diff --git a/src/features/bililge/components/BililgeRentalSheet.tsx b/src/features/bililge/components/BililgeRentalSheet.tsx index 719b737..8912f63 100644 --- a/src/features/bililge/components/BililgeRentalSheet.tsx +++ b/src/features/bililge/components/BililgeRentalSheet.tsx @@ -9,8 +9,8 @@ import { IconCircleInfo } from "@wanteddev/wds-icon"; import { useEffect, useState } from "react"; import BottomSheet from "@/components/ui/BottomSheet"; +import type { BililgeItem } from "@/entities/bililge/types"; import BililgeItemCard from "@/features/bililge/components/BililgeItemCard"; -import type { BililgeItem } from "@/features/bililge/constants/bililgeItems"; interface BililgeRentalSheetProps { item: BililgeItem | null; diff --git a/src/features/events/EventsDetailScreen.tsx b/src/features/events/EventsDetailScreen.tsx index 941f4a8..cd4ea7c 100644 --- a/src/features/events/EventsDetailScreen.tsx +++ b/src/features/events/EventsDetailScreen.tsx @@ -1,183 +1,25 @@ -import { - ActionArea, - ActionAreaButton, - Divider, - TopNavigationButton, - Typography, -} from "@wanteddev/wds"; -import { IconChevronLeft } from "@wanteddev/wds-icon"; +import { Suspense } 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"; +import EventsDetailContent from "@/features/events/components/EventsDetailContent"; +import EventsDetailSkeleton from "@/features/events/components/EventsDetailSkeleton"; -// Figma: 행사 상세 (nodeId 1133:42433 모집중 / 1156:53992 모집예정) -// -// 헤더를 useScreenHeader로 등록하지 않는다 — Figma는 뒤로가기 버튼이 Hero 이미지 위에 떠 있는 -// 오버레이인데 ScreenLayout의 헤더 슬롯은 본문 위에 자리를 차지하는 구조라 그대로는 못 맞춘다. -// 훅을 호출하지 않으면 슬롯이 null(0px)로 남아서, Hero가 화면 최상단부터 시작한다. -// 공용 ScreenHeader에 overlay 옵션을 넣는 방안도 검토했지만 다른 화면에 영향이 가서 로컬로 뒀다. -// -// 상태별로 갈리는 건 뱃지와 하단 CTA뿐이다 — 모집중만 활성이고 나머지는 disabled에 -// 목데이터의 actionLabel("8월 10일 오픈" / "모집종료")이 그대로 들어간다(목록 카드와 같은 규칙). +// 행사 상세 화면 — 이동 같은 화면 동작만 정하고, 행사 데이터를 받는 동안은 상세 배치를 따른 +// 스켈레톤을 보여준다. 헤더가 없는 화면이라(EventsDetailContent 주석 참고) 화면 전체가 데이터 영역이다. function EventsDetailScreen() { const navigate = useNavigate(); - const { eventId } = useParams<{ eventId: string }>(); - const event = EVENTS.find((item) => item.id === eventId); - - if (!event) { - return ( -
- -
- ); - } - - const isOpen = event.status === "open"; - const hasMultipleImages = event.imageCount > 1; + const { eventId = "" } = useParams<{ eventId: string }>(); return ( - // Figma 상세 루트 배경은 Background/Normal/Normal(#FFFFFF)이다 — ScreenLayout 기본 - // 배경(Background/Normal/Alternative, #F7F7F8)과 다르다. 흰 배경이어야 Action Area의 - // 흰 그라데이션이 본문으로 자연스럽게 사라진다(회색 위에 깔면 경계선으로 보인다). - // - // Action Area는 Figma처럼 absolute로 본문을 덮는다. 아래 형제로 두면 스크롤 영역이 - // Action Area 위에서 끝나 버려서, 글이 그 아래로 흘러 들어가며 사라지는 효과가 안 난다. -
-
- {/* Hero — 실제 행사 이미지 API 전까지 Figma와 같은 단색 placeholder. - Figma는 375×375 정사각이라 폭이 유동인 지금 레이아웃에서는 aspect-square로 둔다. - 이미지가 여러 장이면 가로 스크롤 스냅으로 한 장씩 넘긴다(공지 상세와 같은 PhotoGallery). */} - - navigate(-1)} - variant="icon" - > - - -
- } - photoCount={event.imageCount} - showCounter={hasMultipleImages} - slideClassName="aspect-square" - /> - -
-
-
- - - {event.title} - -
- - {/* Figma는 라벨 칼럼(28px)과 값 칼럼을 gap 12로 나란히 두고, 각 칼럼 안은 gap 8이다 */} -
-
- {["일시", "장소", "대상"].map((label) => ( - - {label} - - ))} -
-
- - {event.schedule} - - - {event.location} - - {/* 대상만 여러 줄 — Figma도 한 값 안에서 줄을 나눠 놨다 */} -
- {event.audience.map((line) => ( - - {line} - - ))} -
-
-
-
- - - - {/* 본문은 Figma "Label 1/Reading - Regular"(14px, line-height 1.571) = label1-reading. - 목데이터가 줄바꿈을 그대로 들고 있어서 whitespace-pre-wrap으로 살린다. */} - - {event.description} - -
- - {/* Action Area가 본문을 덮으므로 끝부분이 영구히 가리지 않도록 같은 높이를 비워둔다. - 96px = Action Area 위아래 padding 20+20 + 버튼 56. 데스크톱 프레임에서는 - 홈 인디케이터 여백 14px이 더 붙는다(아래 filler와 같은 규칙). */} -
-
- -
- - {/* WDS ActionAreaButton은 항상 Button size="large"(padding 12px 28px → 48px)로 그리는데, - Figma Main Action은 padding 16px 28px(56px)이라 세로 padding만 sx로 맞춘다. */} - - navigate(`/events/${event.id}/apply`, { viewTransition: true }) - } - sx={{ paddingBlock: "16px" }} - > - {event.actionLabel} - - - {/* Figma Action Area(110px)는 버튼 아래가 iOS Home Bar 여백까지 합쳐 34px인데, - WDS ActionArea는 아래 padding 20px만 준다 — 모자란 14px을 여기서 더한다. - 앱 WebView에서는 네이티브 세이프에어리어와 중복이라 데스크톱 프레임에서만 남긴다(BottomNav와 같은 규칙). */} -
-
-
+ }> + + navigate(`/events/${id}/apply`, { viewTransition: true }) + } + onBack={() => navigate(-1)} + /> + ); } diff --git a/src/features/events/EventsListScreen.tsx b/src/features/events/EventsListScreen.tsx index f1be769..2bdfd49 100644 --- a/src/features/events/EventsListScreen.tsx +++ b/src/features/events/EventsListScreen.tsx @@ -1,50 +1,19 @@ import { - Divider, SegmentedControl, SegmentedControlItem, TopNavigationButton, Typography, } from "@wanteddev/wds"; import { IconBell, IconSearch } from "@wanteddev/wds-icon"; -import { Fragment, useState } from "react"; +import { Suspense, useState } from "react"; import { useNavigate } from "react-router-dom"; import FilterChipGroup from "@/components/ui/FilterChipGroup"; import ScreenHeader from "@/components/ui/ScreenHeader"; import { useScreenHeader } from "@/components/ui/useScreenHeader"; -import EventsCard from "@/features/events/components/EventsCard"; -import EventsEmptyState from "@/features/events/components/EventsEmptyState"; -import { - EVENT_STATUS_FILTERS, - EVENTS, -} from "@/features/events/constants/events"; - -// Figma: 행사 모집중 empty (nodeId 1165:62713)는 "모집중" 필터 버전만 준다 — -// 일러스트·레이아웃·"아카이빙 둘러보기" 버튼이 이 조합의 스펙이다. -// 나머지 필터 문구는 디자인에 없어서 같은 톤으로 맞춰 쓴 것이고, 확정 문구가 나오면 교체한다. -// 버튼은 Figma가 지정한 모집중에만 노출한다 — 모집종료 필터에서 "지난 행사를 보세요"는 모순이 된다. -const EMPTY_STATE_BY_FILTER: Record< - string, - { title: string; description: string; actionLabel?: string } -> = { - all: { - description: "새로운 행사가 열리면 알려드릴게요", - title: "등록된 행사가 없어요", - }, - closed: { - description: "종료된 행사가 아직 없어요", - title: "지난 행사가 없어요", - }, - open: { - actionLabel: "아카이빙 둘러보기", - description: "지난 행사의 활동을 확인해 보세요", - title: "모집 중인 행사가 없어요", - }, - upcoming: { - description: "새로운 행사가 열리면 알려드릴게요", - title: "모집 예정인 행사가 없어요", - }, -}; +import EventsList from "@/features/events/components/EventsList"; +import EventsListSkeleton from "@/features/events/components/EventsListSkeleton"; +import { EVENT_STATUS_FILTERS } from "@/features/events/constants/events"; // Figma: 행사 (nodeId 1243:70854) function EventsListScreen() { @@ -68,12 +37,6 @@ function EventsListScreen() { />, ); - // 빌릴게 카테고리 필터와 달리 모집 상태는 목데이터에 이미 들어있어서 실제로 걸러낼 수 있다. - const visibleEvents = - statusFilter === "all" - ? EVENTS - : EVENTS.filter((event) => event.status === statusFilter); - return ( <> {/* 행사/신청내역 토글 + 필터는 화면마다 값·동작이 달라 헤더가 아니라 화면이 직접 그린다. @@ -99,43 +62,19 @@ function EventsListScreen() {
- {tab === "event" && visibleEvents.length === 0 ? ( - // Figma는 Empty State를 목록 영역(헤더·Bottom Nav 사이) 가운데에 둔다. - // 카드 목록과 같은 gap-6 래퍼 안에 넣으면 flex-1이 높이를 못 받아 위에 붙어버려서, - // 빈 목록일 때는 래퍼를 대체해 h-full로 가운데 정렬한다. -
- }> + + navigate(`/events/${eventId}/apply`, { viewTransition: true }) + } + onSelect={(eventId) => + navigate(`/events/${eventId}`, { viewTransition: true }) + } + statusFilter={statusFilter} /> -
- ) : tab === "event" ? ( -
- {visibleEvents.map((event, index) => ( - - {index > 0 && ( -
- -
- )} - - navigate(`/events/${event.id}/apply`, { - viewTransition: true, - }) - } - onSelect={() => - navigate(`/events/${event.id}`, { viewTransition: true }) - } - status={event.status} - statusLabel={event.statusLabel} - title={event.title} - /> -
- ))} -
+ ) : (
void; + onApply: (eventId: string) => void; +} + +// Figma: 행사 상세 (nodeId 1133:42433 모집중 / 1156:53992 모집예정) +// 행사 데이터를 받아 그리는 부분. 데이터를 받는 동안은 EventsDetailScreen의 Suspense가 +// EventsDetailSkeleton을 보여준다. 이동 같은 화면 동작은 Screen이 콜백으로 넘긴다. +// +// 헤더를 useScreenHeader로 등록하지 않는다 — Figma는 뒤로가기 버튼이 Hero 이미지 위에 떠 있는 +// 오버레이인데 ScreenLayout의 헤더 슬롯은 본문 위에 자리를 차지하는 구조라 그대로는 못 맞춘다. +// 훅을 호출하지 않으면 슬롯이 null(0px)로 남아서, Hero가 화면 최상단부터 시작한다. +// 공용 ScreenHeader에 overlay 옵션을 넣는 방안도 검토했지만 다른 화면에 영향이 가서 로컬로 뒀다. +// +// 상태별로 갈리는 건 뱃지와 하단 CTA뿐이다 — 모집중만 활성이고 나머지는 disabled에 +// 목데이터의 actionLabel("8월 10일 오픈" / "모집종료")이 그대로 들어간다(목록 카드와 같은 규칙). +function EventsDetailContent({ + eventId, + onBack, + onApply, +}: EventsDetailContentProps) { + const { data: event } = useSuspenseQuery(eventsQueries.detail(eventId)); + + if (!event) { + return ( +
+ +
+ ); + } + + const isOpen = event.status === "open"; + const hasMultipleImages = event.imageCount > 1; + + return ( + // Figma 상세 루트 배경은 Background/Normal/Normal(#FFFFFF)이다 — ScreenLayout 기본 + // 배경(Background/Normal/Alternative, #F7F7F8)과 다르다. 흰 배경이어야 Action Area의 + // 흰 그라데이션이 본문으로 자연스럽게 사라진다(회색 위에 깔면 경계선으로 보인다). + // + // Action Area는 Figma처럼 absolute로 본문을 덮는다. 아래 형제로 두면 스크롤 영역이 + // Action Area 위에서 끝나 버려서, 글이 그 아래로 흘러 들어가며 사라지는 효과가 안 난다. +
+
+ {/* Hero — 실제 행사 이미지 API 전까지 Figma와 같은 단색 placeholder. + Figma는 375×375 정사각이라 폭이 유동인 지금 레이아웃에서는 aspect-square로 둔다. + 이미지가 여러 장이면 가로 스크롤 스냅으로 한 장씩 넘긴다(공지 상세와 같은 PhotoGallery). */} + + + + +
+ } + photoCount={event.imageCount} + showCounter={hasMultipleImages} + slideClassName="aspect-square" + /> + +
+
+
+ + + {event.title} + +
+ + {/* Figma는 라벨 칼럼(28px)과 값 칼럼을 gap 12로 나란히 두고, 각 칼럼 안은 gap 8이다 */} +
+
+ {["일시", "장소", "대상"].map((label) => ( + + {label} + + ))} +
+
+ + {event.schedule} + + + {event.location} + + {/* 대상만 여러 줄 — Figma도 한 값 안에서 줄을 나눠 놨다 */} +
+ {event.audience.map((line) => ( + + {line} + + ))} +
+
+
+
+ + + + {/* 본문은 Figma "Label 1/Reading - Regular"(14px, line-height 1.571) = label1-reading. + 목데이터가 줄바꿈을 그대로 들고 있어서 whitespace-pre-wrap으로 살린다. */} + + {event.description} + +
+ + {/* Action Area가 본문을 덮으므로 끝부분이 영구히 가리지 않도록 같은 높이를 비워둔다. + 96px = Action Area 위아래 padding 20+20 + 버튼 56. 데스크톱 프레임에서는 + 홈 인디케이터 여백 14px이 더 붙는다(아래 filler와 같은 규칙). */} +
+
+ +
+ + {/* WDS ActionAreaButton은 항상 Button size="large"(padding 12px 28px → 48px)로 그리는데, + Figma Main Action은 padding 16px 28px(56px)이라 세로 padding만 sx로 맞춘다. */} + onApply(event.id)} + sx={{ paddingBlock: "16px" }} + > + {event.actionLabel} + + + {/* Figma Action Area(110px)는 버튼 아래가 iOS Home Bar 여백까지 합쳐 34px인데, + WDS ActionArea는 아래 padding 20px만 준다 — 모자란 14px을 여기서 더한다. + 앱 WebView에서는 네이티브 세이프에어리어와 중복이라 데스크톱 프레임에서만 남긴다(BottomNav와 같은 규칙). */} +
+
+
+ ); +} + +export default EventsDetailContent; diff --git a/src/features/events/components/EventsDetailSkeleton.tsx b/src/features/events/components/EventsDetailSkeleton.tsx index 6938f63..7593fb3 100644 --- a/src/features/events/components/EventsDetailSkeleton.tsx +++ b/src/features/events/components/EventsDetailSkeleton.tsx @@ -5,7 +5,7 @@ import { useSkeletonAnimation, } from "@/components/ui/ScreenSkeleton"; -// EventsDetailScreen이 로딩되는 동안의 자리 — 정사각 Hero 이미지, 상태 뱃지·제목, 일시/장소/대상 +// 행사 데이터를 받는 동안 EventsDetailScreen에 보이는 자리 — 정사각 Hero 이미지, 상태 뱃지·제목, 일시/장소/대상 // 정보 행을 그대로 따른다. 실제 화면처럼 헤더 없이 Hero가 맨 위에서 시작하므로 헤더 슬롯은 비워 둔다. function EventsDetailSkeleton() { const animation = useSkeletonAnimation(); diff --git a/src/features/events/components/EventsList.tsx b/src/features/events/components/EventsList.tsx new file mode 100644 index 0000000..16b68bb --- /dev/null +++ b/src/features/events/components/EventsList.tsx @@ -0,0 +1,91 @@ +import { useSuspenseQuery } from "@tanstack/react-query"; +import { Divider } from "@wanteddev/wds"; +import { Fragment } from "react"; + +import { eventsQueries } from "@/entities/events/eventsQueries"; +import EventsCard from "@/features/events/components/EventsCard"; +import EventsEmptyState from "@/features/events/components/EventsEmptyState"; + +// Figma: 행사 모집중 empty (nodeId 1165:62713)는 "모집중" 필터 버전만 준다 — +// 일러스트·레이아웃·"아카이빙 둘러보기" 버튼이 이 조합의 스펙이다. +// 나머지 필터 문구는 디자인에 없어서 같은 톤으로 맞춰 쓴 것이고, 확정 문구가 나오면 교체한다. +// 버튼은 Figma가 지정한 모집중에만 노출한다 — 모집종료 필터에서 "지난 행사를 보세요"는 모순이 된다. +const EMPTY_STATE_BY_FILTER: Record< + string, + { title: string; description: string; actionLabel?: string } +> = { + all: { + description: "새로운 행사가 열리면 알려드릴게요", + title: "등록된 행사가 없어요", + }, + closed: { + description: "종료된 행사가 아직 없어요", + title: "지난 행사가 없어요", + }, + open: { + actionLabel: "아카이빙 둘러보기", + description: "지난 행사의 활동을 확인해 보세요", + title: "모집 중인 행사가 없어요", + }, + upcoming: { + description: "새로운 행사가 열리면 알려드릴게요", + title: "모집 예정인 행사가 없어요", + }, +}; + +interface EventsListProps { + statusFilter: string; + onSelect: (eventId: string) => void; + onApply: (eventId: string) => void; +} + +// 행사 목록 데이터를 받아 그리는 부분. 데이터를 받는 동안은 EventsListScreen의 Suspense가 +// EventsListSkeleton을 보여준다. 이동 같은 화면 동작은 Screen이 콜백으로 넘긴다. +function EventsList({ statusFilter, onSelect, onApply }: EventsListProps) { + const { data: events } = useSuspenseQuery(eventsQueries.list()); + + // 빌릴게 카테고리 필터와 달리 모집 상태는 목데이터에 이미 들어있어서 실제로 걸러낼 수 있다. + const visibleEvents = + statusFilter === "all" + ? events + : events.filter((event) => event.status === statusFilter); + + if (visibleEvents.length === 0) { + return ( + // Figma는 Empty State를 목록 영역(헤더·Bottom Nav 사이) 가운데에 둔다. + // 카드 목록과 같은 gap-6 래퍼 안에 넣으면 flex-1이 높이를 못 받아 위에 붙어버려서, + // 빈 목록일 때는 래퍼를 대체해 h-full로 가운데 정렬한다. +
+ +
+ ); + } + + return ( +
+ {visibleEvents.map((event, index) => ( + + {index > 0 && ( +
+ +
+ )} + onApply(event.id)} + onSelect={() => onSelect(event.id)} + status={event.status} + statusLabel={event.statusLabel} + title={event.title} + /> +
+ ))} +
+ ); +} + +export default EventsList; diff --git a/src/features/events/components/EventsListSkeleton.tsx b/src/features/events/components/EventsListSkeleton.tsx index c72b71e..942e83e 100644 --- a/src/features/events/components/EventsListSkeleton.tsx +++ b/src/features/events/components/EventsListSkeleton.tsx @@ -2,81 +2,69 @@ import { Divider, Skeleton } from "@wanteddev/wds"; import { Fragment } from "react"; import { - FilterChipsSkeleton, - SegmentedControlSkeleton, SKELETON_ROW_KEYS, - useScreenHeaderSkeleton, useSkeletonAnimation, } from "@/components/ui/ScreenSkeleton"; -// EventsListScreen이 로딩되는 동안의 자리 — 행사/신청내역 토글, 모집 상태 칩, 행사 카드(EventsCard: -// 112px 썸네일 + 뱃지·제목·날짜 + 우하단 버튼) 배치를 그대로 따른다. +// 행사 목록 데이터를 받는 동안 EventsListScreen의 목록 자리에 보이는 스켈레톤 — 헤더·토글·필터는 +// 화면이 바로 그리므로 행사 카드(EventsCard: 112px 썸네일 + 뱃지·제목·날짜 + 우하단 버튼) 배치만 따른다. function EventsListSkeleton() { const animation = useSkeletonAnimation(); - useScreenHeaderSkeleton(); return ( -
-
- -
-
- -
-
- {SKELETON_ROW_KEYS.map((key, index) => ( - - {index > 0 && ( -
- +
+ {SKELETON_ROW_KEYS.map((key, index) => ( + + {index > 0 && ( +
+ +
+ )} +
+ +
+
+ + +
- )} -
- -
-
- - - -
-
- -
+
+
- - ))} -
+
+ + ))}
); } diff --git a/src/features/events/components/EventsStatusBadge.tsx b/src/features/events/components/EventsStatusBadge.tsx index 14d6c0d..c30cf9e 100644 --- a/src/features/events/components/EventsStatusBadge.tsx +++ b/src/features/events/components/EventsStatusBadge.tsx @@ -1,6 +1,6 @@ import { ContentBadge } from "@wanteddev/wds"; -import type { EventStatus } from "@/features/events/constants/events"; +import type { EventStatus } from "@/entities/events/types"; interface EventsStatusBadgeProps { status: EventStatus; diff --git a/src/features/events/constants/events.ts b/src/features/events/constants/events.ts index 85e2130..64ac9c9 100644 --- a/src/features/events/constants/events.ts +++ b/src/features/events/constants/events.ts @@ -1,24 +1,3 @@ -export type EventStatus = "open" | "upcoming" | "closed"; - -export interface EventItem { - id: string; - title: string; - eventDate: string; - status: EventStatus; - statusLabel: string; - actionLabel: string; - /** 상세 상단 Hero 이미지 개수 — 실 이미지 API 전까지 PageCounter 표기용 */ - imageCount: number; - /** 상세 메타데이터 "일시" */ - schedule: string; - /** 상세 메타데이터 "장소" */ - location: string; - /** 상세 메타데이터 "대상" — Figma가 두 줄로 쪼개 보여줘서 줄 단위로 들고 있는다 */ - audience: string[]; - /** 상세 본문. 줄바꿈을 그대로 살려 렌더링한다 */ - description: string; -} - // 모집 상태 필터. value는 EventStatus + "all"이고, 라벨은 Figma Filter Chips(1243:70861) 그대로. export const EVENT_STATUS_FILTERS = [ { label: "전체", value: "all" }, @@ -26,73 +5,3 @@ export const EVENT_STATUS_FILTERS = [ { label: "모집예정", value: "upcoming" }, { label: "모집종료", value: "closed" }, ] as const; - -// Figma 상세(1133:42433 모집중 / 1156:53992 모집예정)의 메타데이터·본문은 목업이라 행사별로 다르지 않다. -// 목록 카드가 같은 행사명 4개를 상태만 바꿔 보여주는 것과 같은 이유로, 상세 내용도 공통 상수로 두고 -// 상태별로 갈리는 값(뱃지 문구, CTA 문구)만 항목마다 다르게 준다. -const MOCK_SCHEDULE = "4월 15일 (수) 18:30 ~"; -const MOCK_LOCATION = "미래관 4층 신관 입구 (예대 방면)"; -const MOCK_AUDIENCE = ["소프트웨어융합대학 과학생회비 납부자", "선착순 150명"]; -const MOCK_DESCRIPTION = `안녕하십니까, 제10대 소프트웨어융합대학 학생회 ‘에코’입니다. - -기말고사를 준비하고 계신 학우 여러분을 응원하기 위해 간식행사를 진행합니다 🍱✨ - -시험기간 동안 든든하게 힘내시길 바라며, 많은 관심과 참여 부탁드립니다! - -📌 간식행사 일정 -▪️ 일시 : 6월 1일 (월) 11:00 ~ -▪️ 장소 : 미래관 4층 신관 입구 (예대방면) - -📌 대상 -▪️ 소프트웨어융합대학 재학생 선착순 180명 -※ 과학생회비 미납부자 참여 가능 - -📌 간식행사 메뉴 -▪️ 돈까스 도련님 도시락 -▪️ 나랑드사이다 제로 - -📌 유의 사항 -▪️ 소프트웨어융합대학 학생임을 증명할 수 있는 모바일 학생증 혹은 실물 학생증을 지참해주시기 바랍니다. -▪️ 1인당 1세트만 수령 가능하며, 선착순 수량 소진 시 수령이 불가능합니다. - -많은 학우 여러분의 관심과 참여 부탁드립니다. -감사합니다 😊`; - -// Figma: 행사 Event List (nodeId 1243:70866) 문구를 그대로 옮긴 목데이터 — 실 API 연동 전까지 사용. -// -// Figma 목업은 같은 행사명 4개(모집중 1 / 모집예정 1 / 모집종료 2)를 상태만 바꿔 보여주는데, -// 여기서는 모집중을 비우고 모집종료 중복도 하나로 줄였다(모집예정 1 / 모집종료 1). -// -// Empty State(1165:62713)는 Figma가 "모집중" 필터 버전으로만 그려져 있고, 일러스트·문구와 -// "아카이빙 둘러보기" 버튼이 그 조합의 스펙이다. 목데이터에 모집중 항목이 있으면 이 화면을 -// 아예 볼 수 없어서 모집중을 비웠다. 대신 모집중 상세(1133:42433)는 카드로 진입할 수 없다 — -// 실 API가 붙으면 사라질 제약이고, 지금 확인이 필요하면 아래 항목 하나의 status를 "open"으로 -// 되돌리면 된다. 모집예정은 상세 디자인(1156:53992)이 있어 진입 가능하게 남겼다. -export const EVENTS: EventItem[] = [ - { - actionLabel: "모집종료", - audience: MOCK_AUDIENCE, - description: MOCK_DESCRIPTION, - eventDate: "행사일 2026.06.04", - id: "sw-sports-day-closed", - imageCount: 7, - location: MOCK_LOCATION, - schedule: MOCK_SCHEDULE, - status: "closed", - statusLabel: "모집종료", - title: "소프트웨어융합대학 체육대회", - }, - { - actionLabel: "8월 10일 오픈", - audience: MOCK_AUDIENCE, - description: MOCK_DESCRIPTION, - eventDate: "행사일 2026.06.04", - id: "sw-sports-day-upcoming", - imageCount: 7, - location: MOCK_LOCATION, - schedule: MOCK_SCHEDULE, - status: "upcoming", - statusLabel: "모집예정", - title: "소프트웨어융합대학 체육대회", - }, -]; diff --git a/src/features/notices/NoticesDetailScreen.tsx b/src/features/notices/NoticesDetailScreen.tsx index 4d8576a..bdbbcc4 100644 --- a/src/features/notices/NoticesDetailScreen.tsx +++ b/src/features/notices/NoticesDetailScreen.tsx @@ -1,121 +1,19 @@ -import { - ContentBadge, - type ThemeColorsToken, - TopNavigationButton, - Typography, -} from "@wanteddev/wds"; -import { IconChevronLeft } from "@wanteddev/wds-icon"; +import { Suspense } from "react"; 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 { - NOTICES, - type NoticeCategory, -} from "@/features/notices/constants/notices"; +import NoticesDetailContent from "@/features/notices/components/NoticesDetailContent"; +import NoticesDetailSkeleton from "@/features/notices/components/NoticesDetailSkeleton"; -const CATEGORY_BADGE_COLOR: Record = { - 일반: "semantic.accent.foreground.blue", - 제휴: "semantic.accent.foreground.redOrange", -}; - -// Figma: 공지 상세 (nodeId 1256:81842), 공지 상세 - 사진 없을 때 (nodeId 1256:81856) +// 공지 상세 화면 — 이동 같은 화면 동작만 정하고, 공지 데이터를 받는 동안은 상세 배치를 따른 +// 스켈레톤을 보여준다. 헤더도 데이터(사진 유무)에 따라 달라서 화면 전체가 데이터 영역이다. 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( - notice?.hasThumbnail ? null : ( - - ), - ); - - if (!notice) { - return ( -
- - 존재하지 않는 공지예요 - -
- ); - } + const { noticeId = "" } = useParams<{ noticeId: string }>(); return ( -
- {notice.hasThumbnail && ( - {backButton}
- } - photoCount={photoCount} - showCounter - slideClassName="aspect-square" - /> - )} - -
-
- - {notice.category} 공지 - -
- - {notice.title} - - - 등록일 {notice.date} - -
-
- - - {notice.body} - -
-
+ }> + navigate(-1)} /> + ); } diff --git a/src/features/notices/NoticesListScreen.tsx b/src/features/notices/NoticesListScreen.tsx index 3d94dc6..6a3aa39 100644 --- a/src/features/notices/NoticesListScreen.tsx +++ b/src/features/notices/NoticesListScreen.tsx @@ -1,21 +1,13 @@ -import { - Divider, - Tab, - TabList, - TabListItem, - TopNavigationButton, -} from "@wanteddev/wds"; +import { Tab, TabList, TabListItem, TopNavigationButton } from "@wanteddev/wds"; import { IconBell, IconSearch } from "@wanteddev/wds-icon"; -import { Fragment, useState } from "react"; -import { Link, useNavigate } from "react-router-dom"; +import { Suspense, useState } from "react"; +import { useNavigate } from "react-router-dom"; import ScreenHeader from "@/components/ui/ScreenHeader"; import { useScreenHeader } from "@/components/ui/useScreenHeader"; -import NoticesCard from "@/features/notices/components/NoticesCard"; -import { - NOTICES, - type NoticeCategory, -} from "@/features/notices/constants/notices"; +import type { NoticeCategory } from "@/entities/notices/types"; +import NoticesList from "@/features/notices/components/NoticesList"; +import NoticesListSkeleton from "@/features/notices/components/NoticesListSkeleton"; type NoticeTab = "all" | "general" | "partnership"; @@ -53,10 +45,6 @@ function NoticesListScreen() { />, ); - const notices = NOTICES.filter( - (notice) => tab === "all" || notice.category === TAB_CATEGORY[tab], - ); - return ( <> {/* 전체/일반 공지/제휴 공지 탭은 Figma에서 타이틀 행 아래("Tool" 영역)에 붙지만, @@ -73,30 +61,10 @@ function NoticesListScreen() {
-
- {notices.map((notice, index) => ( - - {index > 0 && ( -
- -
- )} - - - -
- ))} -
+ {/* 헤더·탭은 데이터와 무관해서 바로 그리고, 데이터를 받는 목록 자리만 스켈레톤으로 채운다 */} + }> + +
); diff --git a/src/features/notices/components/NoticesDetailContent.tsx b/src/features/notices/components/NoticesDetailContent.tsx new file mode 100644 index 0000000..9dd1c07 --- /dev/null +++ b/src/features/notices/components/NoticesDetailContent.tsx @@ -0,0 +1,121 @@ +import { useSuspenseQuery } from "@tanstack/react-query"; +import { + ContentBadge, + type ThemeColorsToken, + TopNavigationButton, + Typography, +} from "@wanteddev/wds"; +import { IconChevronLeft } from "@wanteddev/wds-icon"; + +import PhotoGallery from "@/components/ui/PhotoGallery"; +import ScreenHeader from "@/components/ui/ScreenHeader"; +import { useScreenHeader } from "@/components/ui/useScreenHeader"; +import { noticesQueries } from "@/entities/notices/noticesQueries"; +import type { NoticeCategory } from "@/entities/notices/types"; + +const CATEGORY_BADGE_COLOR: Record = { + 일반: "semantic.accent.foreground.blue", + 제휴: "semantic.accent.foreground.redOrange", +}; + +interface NoticesDetailContentProps { + noticeId: string; + onBack: () => void; +} + +// Figma: 공지 상세 (nodeId 1256:81842), 공지 상세 - 사진 없을 때 (nodeId 1256:81856) +// 공지 데이터를 받아 그리는 부분. 데이터를 받는 동안은 NoticesDetailScreen의 Suspense가 +// NoticesDetailSkeleton을 보여준다. 헤더 모양이 공지의 사진 유무로 갈려서 헤더도 여기서 등록한다. +function NoticesDetailContent({ noticeId, onBack }: NoticesDetailContentProps) { + const { data: notice } = useSuspenseQuery(noticesQueries.detail(noticeId)); + const photoCount = notice?.photoCount ?? 0; + + const backButton = ( + + + + ); + + // 사진이 있으면 행사 상세와 같은 방식으로 뒤로가기를 사진 위 오버레이로 그려서 사진이 화면 + // 최상단부터 시작하게 한다(ScreenLayout 헤더 슬롯을 비우면 0px로 접힌다). 사진이 없으면 + // 덮을 이미지가 없어서 기존처럼 흰 배경 헤더를 쓴다. + useScreenHeader( + notice?.hasThumbnail ? null : ( + + ), + ); + + if (!notice) { + return ( +
+ + 존재하지 않는 공지예요 + +
+ ); + } + + return ( +
+ {notice.hasThumbnail && ( + {backButton}
+ } + photoCount={photoCount} + showCounter + slideClassName="aspect-square" + /> + )} + +
+
+ + {notice.category} 공지 + +
+ + {notice.title} + + + 등록일 {notice.date} + +
+
+ + + {notice.body} + +
+
+ ); +} + +export default NoticesDetailContent; diff --git a/src/features/notices/components/NoticesDetailSkeleton.tsx b/src/features/notices/components/NoticesDetailSkeleton.tsx index 7085200..09b5c36 100644 --- a/src/features/notices/components/NoticesDetailSkeleton.tsx +++ b/src/features/notices/components/NoticesDetailSkeleton.tsx @@ -6,7 +6,7 @@ import { useSkeletonAnimation, } from "@/components/ui/ScreenSkeleton"; -// NoticesDetailScreen이 로딩되는 동안의 자리 — 카테고리 뱃지, 제목, 등록일, 본문 문단을 따른다. +// 공지 데이터를 받는 동안 NoticesDetailScreen에 보이는 자리 — 카테고리 뱃지, 제목, 등록일, 본문 문단을 따른다. // 사진 유무는 공지마다 달라서 사진 자리는 그리지 않는다. function NoticesDetailSkeleton() { const animation = useSkeletonAnimation(); diff --git a/src/features/notices/components/NoticesList.tsx b/src/features/notices/components/NoticesList.tsx new file mode 100644 index 0000000..0ef1f55 --- /dev/null +++ b/src/features/notices/components/NoticesList.tsx @@ -0,0 +1,46 @@ +import { useSuspenseQuery } from "@tanstack/react-query"; +import { Divider } from "@wanteddev/wds"; +import { Fragment } from "react"; +import { Link } from "react-router-dom"; + +import { noticesQueries } from "@/entities/notices/noticesQueries"; +import type { NoticeCategory } from "@/entities/notices/types"; +import NoticesCard from "@/features/notices/components/NoticesCard"; + +interface NoticesListProps { + category: NoticeCategory | "all"; +} + +// 공지 목록 데이터를 받아 그리는 부분. 데이터를 받는 동안은 NoticesListScreen의 Suspense가 +// NoticesListSkeleton을 보여준다. +function NoticesList({ category }: NoticesListProps) { + const { data } = useSuspenseQuery(noticesQueries.list()); + const notices = data.filter( + (notice) => category === "all" || notice.category === category, + ); + + return ( +
+ {notices.map((notice, index) => ( + + {index > 0 && ( +
+ +
+ )} + + + +
+ ))} +
+ ); +} + +export default NoticesList; diff --git a/src/features/notices/components/NoticesListSkeleton.tsx b/src/features/notices/components/NoticesListSkeleton.tsx index 3ace513..6389854 100644 --- a/src/features/notices/components/NoticesListSkeleton.tsx +++ b/src/features/notices/components/NoticesListSkeleton.tsx @@ -3,7 +3,6 @@ import { Fragment } from "react"; import { SKELETON_ROW_KEYS, - useScreenHeaderSkeleton, useSkeletonAnimation, } from "@/components/ui/ScreenSkeleton"; @@ -11,74 +10,49 @@ import { // 실제 목데이터처럼 몇 행에만 둔다. const ROWS_WITH_THUMBNAIL = new Set(["row-1", "row-2", "row-5"]); -// NoticesListScreen(게시판 공지 탭)이 로딩되는 동안의 자리 — 카테고리 탭 줄, 제목·날짜 행을 따른다. +// 공지 목록 데이터를 받는 동안 NoticesListScreen의 목록 자리에 보이는 스켈레톤 — 헤더·카테고리 탭은 +// 화면이 바로 그리므로 제목·날짜 행만 따른다. function NoticesListSkeleton() { const animation = useSkeletonAnimation(); - useScreenHeaderSkeleton(); return ( -
-
- - - -
-
- {SKELETON_ROW_KEYS.map((key, index) => ( - - {index > 0 && ( -
- -
- )} -
-
- - -
- {ROWS_WITH_THUMBNAIL.has(key) && ( - - )} +
+ {SKELETON_ROW_KEYS.map((key, index) => ( + + {index > 0 && ( +
+ +
+ )} +
+
+ +
- - ))} -
+ {ROWS_WITH_THUMBNAIL.has(key) && ( + + )} +
+ + ))}
); } From 302e3eea82e8c49614baccef242b0412bd243c92 Mon Sep 17 00:00:00 2001 From: gain <163835628+leegain1@users.noreply.github.com> Date: Thu, 1 Oct 2026 16:51:07 +0900 Subject: [PATCH 10/12] =?UTF-8?q?docs:=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20?= =?UTF-8?q?=EB=A1=9C=EB=94=A9=20=EC=BB=A8=EB=B2=A4=EC=85=98=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/conventions/coding-style.md | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/docs/conventions/coding-style.md b/docs/conventions/coding-style.md index feefd51..8baf1e8 100644 --- a/docs/conventions/coding-style.md +++ b/docs/conventions/coding-style.md @@ -41,13 +41,20 @@ 새 옵션이 필요하면 `src/app/ScreenLayoutRoute.tsx`의 `ScreenRouteHandle`에 필드를 추가하고, 그 값을 `ScreenLayout` prop으로 넘긴다. 현재 필드는 `hasBottomNav`(하단 탭 표시)와 `background`(375×812 프레임 배경 — 헤더 뒤까지 포함이라 화면 본문에서 칠할 수 없다. 신청 완료처럼 Figma가 흰 배경으로 그린 화면만 `"normal"`)다. - `ScreenLayout`은 **라우터를 모르는 prop 기반 컴포넌트**로 유지한다. 라우트 정보(`useMatches`)는 `ScreenLayoutRoute`만 읽는다. -- 화면 컴포넌트는 `lazyScreen(() => import(...), <스켈레톤 />)`으로 라우트마다 코드 분할한다. 화면 JS를 받는 동안 fallback 스켈레톤이 보인다. 전용 스켈레톤은 `features/<기능>/components/<화면>Skeleton.tsx`에 두고 WDS `Skeleton`으로 실제 배치를 따라 그린다(헤더 자리는 `useScreenHeaderSkeleton`). 전용 스켈레톤이 없으면 기본값(헤더 자리만 채우는 `ScreenSkeleton`)을 쓴다. +- 화면 컴포넌트는 `lazyScreen(() => import(...))`으로 라우트마다 코드 분할한다. 화면 JS를 받는 동안에는 헤더 자리만 채우는 `ScreenSkeleton`이 보인다. 상세처럼 화면 전체가 데이터 영역인 화면은 그 화면의 데이터 스켈레톤을 두 번째 인자로 넘겨, JS 로딩과 데이터 로딩이 같은 스켈레톤으로 이어지게 한다(아래 "데이터 로딩" 참고). - 화면 스택을 쌓는 이동(목록→상세, 상세→신청 등)은 `navigate(to, { viewTransition: true })`·``으로 슬라이드 전환을 켠다. 뒤로가기는 react-router가 그 이동을 기억해 반대 방향으로 자동 적용하므로 `navigate(-1)`은 그대로 둔다. 브라우저 앞으로가기도 POP이라, `ScreenLayoutRoute`는 히스토리 위치(`history.state.idx`)가 줄어든 POP만 뒤로 방향으로 본다. Bottom Nav·상단 탭처럼 형제 화면을 오가는 이동과 홈으로 돌아가는 이동은 켜지 않는다(즉시 전환). 애니메이션은 `index.css`, 방향은 `ScreenLayoutRoute`가 정한다. - 라우트가 없는 경로는 레이아웃 안의 `path: "*"` 라우트(`ComingSoonScreen`)가 받는다. 하단 탭이 유지돼서 다른 화면으로 돌아갈 수 있다. 구체적인 경로가 `*`보다 항상 우선하므로 배열 순서는 신경 쓰지 않아도 된다. +## 데이터 로딩 + +- 서버 데이터는 TanStack Query로 받는다. API 함수·쿼리·타입은 `entities/<도메인>/`에 두고(`<도메인>Api.ts`·`<도메인>Queries.ts`·`types.ts`), 화면은 `useSuspenseQuery(<도메인>Queries.list())`처럼 쿼리 팩토리로만 받는다. +- 실 API 전까지 API 함수는 `<도메인>Mock.ts`의 목데이터를 `mockResponse`(`lib/mockResponse.ts`)로 돌려준다. 개발 서버에서는 스켈레톤을 확인할 수 있게 500ms 늦게 응답하고, 배포 빌드에서는 바로 응답한다. API가 붙으면 API 함수 안쪽만 바꾼다. +- 화면(`<화면>Screen.tsx`)은 헤더 등록, UI 상태, 이동 같은 동작을 맡고, 데이터를 받는 영역만 `}>`으로 감싼다. `useSuspenseQuery`를 부르고 데이터를 그리는 부분은 `features/<기능>/components/`의 컴포넌트(`EventsList`, `EventsDetailContent` 등)로 분리하고, 이동 같은 동작은 콜백 prop으로 받는다. +- 데이터와 무관한 헤더·탭·필터는 Suspense 밖에서 바로 그리고, 스켈레톤은 데이터 영역의 배치만 따라 그린다. 전용 스켈레톤은 `features/<기능>/components/<화면>Skeleton.tsx`에 두고 WDS `Skeleton`으로 그린다. 헤더가 데이터에 따라 달라지는 화면(공지 상세)은 헤더도 데이터 컴포넌트가 등록하고, 스켈레톤이 `useScreenHeaderSkeleton`으로 헤더 자리를 채운다. + ## 에러 / 비동기 -- async는 try/catch 또는 서버 상태 라이브러리(도입 시)의 에러 상태로 다룬다. **빈 catch 금지**. +- async는 try/catch 또는 TanStack Query의 에러 상태로 다룬다. **빈 catch 금지**. - 사용자에게 보이는 메시지와 개발 로깅을 구분한다. ## 주석 From 085124f06fdc257519fc9cad6beabc0617140eae Mon Sep 17 00:00:00 2001 From: gain <163835628+leegain1@users.noreply.github.com> Date: Thu, 1 Oct 2026 17:08:06 +0900 Subject: [PATCH 11/12] =?UTF-8?q?refactor:=20=EB=9D=BC=EC=9A=B0=ED=8A=B8?= =?UTF-8?q?=20=EC=BD=94=EB=93=9C=20=EB=B6=84=ED=95=A0(lazyScreen)=EC=9D=84?= =?UTF-8?q?=20=EC=A0=9C=EA=B1=B0=ED=95=98=EA=B3=A0=20=ED=99=94=EB=A9=B4?= =?UTF-8?q?=EC=9D=84=20=EB=B0=94=EB=A1=9C=20import?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/router.tsx | 95 ++++++------------- src/components/ui/ScreenSkeleton.tsx | 54 +++-------- .../components/NoticesDetailSkeleton.tsx | 2 +- 3 files changed, 42 insertions(+), 109 deletions(-) diff --git a/src/app/router.tsx b/src/app/router.tsx index 239e481..e350fc2 100644 --- a/src/app/router.tsx +++ b/src/app/router.tsx @@ -1,31 +1,24 @@ -import { type ComponentType, lazy, type ReactNode, Suspense } from "react"; import { createBrowserRouter, type RouteObject } from "react-router-dom"; import App from "@/app/App"; +import ComingSoonScreen from "@/app/ComingSoonScreen"; import ScreenLayoutRoute, { type ScreenRouteHandle, } from "@/app/ScreenLayoutRoute"; -import ScreenSkeleton from "@/components/ui/ScreenSkeleton"; -import EventsDetailSkeleton from "@/features/events/components/EventsDetailSkeleton"; -import NoticesDetailSkeleton from "@/features/notices/components/NoticesDetailSkeleton"; - -// 화면은 라우트마다 따로 코드 분할한다. 첫 진입에 받는 JS가 줄고, 화면 JS를 받는 동안에는 -// 헤더 자리만 채우는 ScreenSkeleton(fallback)을 보여준다. 데이터 로딩 스켈레톤은 각 화면이 -// 자기 Suspense로 직접 그린다. -// 상세처럼 화면 전체가 데이터 영역인 화면은 화면 안 스켈레톤을 여기 fallback으로도 넘겨서, -// JS 로딩 → 데이터 로딩이 같은 스켈레톤으로 끊김 없이 이어지게 한다. -// 스켈레톤은 fallback이라 코드 분할하지 않는다(여기서 바로 import). -function lazyScreen( - load: () => Promise<{ default: ComponentType }>, - fallback: ReactNode = , -) { - const Screen = lazy(load); - return ( - - - - ); -} +import BililgeListScreen from "@/features/bililge/BililgeListScreen"; +import ChatEntryScreen from "@/features/chat/ChatEntryScreen"; +import EventsApplicationClosedScreen from "@/features/events/EventsApplicationClosedScreen"; +import EventsApplicationCompleteScreen from "@/features/events/EventsApplicationCompleteScreen"; +import EventsApplicationScreen from "@/features/events/EventsApplicationScreen"; +import EventsDetailScreen from "@/features/events/EventsDetailScreen"; +import EventsListScreen from "@/features/events/EventsListScreen"; +import FeedbacksDetailScreen from "@/features/feedbacks/FeedbacksDetailScreen"; +import FeedbacksListScreen from "@/features/feedbacks/FeedbacksListScreen"; +import FeedbacksNewScreen from "@/features/feedbacks/FeedbacksNewScreen"; +import HomeScreen from "@/features/home/HomeScreen"; +import LockersApplyScreen from "@/features/lockers/LockersApplyScreen"; +import NoticesDetailScreen from "@/features/notices/NoticesDetailScreen"; +import NoticesListScreen from "@/features/notices/NoticesListScreen"; // 앱의 모든 라우트는 이 객체 배열 한곳에서 정의한다 — 새 화면은 여기에 라우트를 추가한다. // satisfies로 선언 시점에 RouteObject 형태를 검사한다. @@ -36,43 +29,33 @@ const routes = [ { children: [ { - element: lazyScreen(() => import("@/features/home/HomeScreen")), + element: , path: "/", }, { - element: lazyScreen( - () => import("@/features/bililge/BililgeListScreen"), - ), + element: , path: "/bililge", }, { - element: lazyScreen( - () => import("@/features/events/EventsListScreen"), - ), + element: , // 카드 없이 구분선으로만 나뉘는 목록이라 화면 전체가 흰 면이다 handle: { background: "normal" } satisfies ScreenRouteHandle, path: "/events", }, { - element: lazyScreen( - () => import("@/features/notices/NoticesListScreen"), - ), + element: , // 카드 없이 구분선으로만 나뉘는 목록이라 화면 전체가 흰 면이다 handle: { background: "normal" } satisfies ScreenRouteHandle, path: "/notices", }, { - element: lazyScreen( - () => import("@/features/feedbacks/FeedbacksListScreen"), - ), + element: , // 공지 화면과 같은 이유(카드 없이 구분선으로만 나뉘는 목록)로 흰 면을 쓴다 handle: { background: "normal" } satisfies ScreenRouteHandle, path: "/feedbacks", }, { - element: lazyScreen( - () => import("@/features/feedbacks/FeedbacksNewScreen"), - ), + element: , // Bottom Nav 대신 하단 고정 버튼(Action Area)이 있는 화면. Figma 루트 배경도 흰 면이다. handle: { background: "normal", @@ -81,9 +64,7 @@ const routes = [ path: "/feedbacks/new", }, { - element: lazyScreen( - () => import("@/features/feedbacks/FeedbacksDetailScreen"), - ), + element: , // 상세(모아보기) 화면 — 목록 화면과 같은 흰 배경, Bottom Nav 없이 뒤로가기(닫기)로만 나간다. handle: { background: "normal", @@ -92,26 +73,19 @@ const routes = [ path: "/feedbacks/:feedbackId", }, { - element: lazyScreen( - () => import("@/features/events/EventsDetailScreen"), - , - ), + element: , // Bottom Nav 대신 하단 고정 버튼(Action Area)이 있는 화면 handle: { hasBottomNav: false } satisfies ScreenRouteHandle, path: "/events/:eventId", }, { - element: lazyScreen( - () => import("@/features/events/EventsApplicationScreen"), - ), + element: , // Bottom Nav 대신 하단 고정 버튼(Action Area)이 있는 화면 handle: { hasBottomNav: false } satisfies ScreenRouteHandle, path: "/events/:eventId/apply", }, { - element: lazyScreen( - () => import("@/features/events/EventsApplicationCompleteScreen"), - ), + element: , // 신청 결과 화면 — 하단 탭 없이 흰 배경 전체 화면이다 handle: { background: "normal", @@ -120,9 +94,7 @@ const routes = [ path: "/events/:eventId/apply/complete", }, { - element: lazyScreen( - () => import("@/features/events/EventsApplicationClosedScreen"), - ), + element: , // 신청 결과 화면 — 하단 탭 없이 흰 배경 전체 화면이다 handle: { background: "normal", @@ -131,10 +103,7 @@ const routes = [ path: "/events/:eventId/apply/closed", }, { - element: lazyScreen( - () => import("@/features/notices/NoticesDetailScreen"), - , - ), + element: , // 상세 화면은 뒤로가기로만 돌아가는 흐름이라 Bottom Nav를 안 보여준다. 카드 없이 // 본문이 배경까지 흰 면이라 목록 화면과 같은 background: "normal"을 쓴다. handle: { @@ -144,16 +113,12 @@ const routes = [ path: "/notices/:noticeId", }, { - element: lazyScreen( - () => import("@/features/lockers/LockersApplyScreen"), - ), + element: , // 사물함 유의사항 시트 확인용 임시 라우트 — 실제 진입점이 붙으면 화면과 함께 지운다 path: "/lockers/apply", }, { - element: lazyScreen( - () => import("@/features/chat/ChatEntryScreen"), - ), + element: , // Figma 챗봇 진입 화면에는 Bottom Nav가 없다(뒤로가기로 홈에 복귀). 흰 배경 위에 // 그라데이션이 얹히는 구조라 다른 흰 배경 화면들과 같은 normal을 쓴다. handle: { @@ -164,7 +129,7 @@ const routes = [ }, // 라우트가 없는 경로 — 레이아웃 안에 둬서 하단 탭이 유지되고, 탭 경로(/event 등)면 그 탭이 활성으로 보인다 { - element: lazyScreen(() => import("@/app/ComingSoonScreen")), + element: , path: "*", }, ], diff --git a/src/components/ui/ScreenSkeleton.tsx b/src/components/ui/ScreenSkeleton.tsx index 26a5d72..221f0ba 100644 --- a/src/components/ui/ScreenSkeleton.tsx +++ b/src/components/ui/ScreenSkeleton.tsx @@ -8,61 +8,29 @@ export function useSkeletonAnimation() { return !usePrefersReducedMotion(); } -type ScreenHeaderSkeletonVariant = "display" | "normal"; - -// ScreenHeader와 같은 56px 자리에 그린다. 헤더가 비어 있다가 실제 화면이 들어올 때 본문이 헤더 -// 높이만큼 밀려 내려가지 않도록, 스켈레톤도 헤더 슬롯을 채운다. -// display는 좌측 큰 타이틀, normal(상세 화면)은 좌상단 24px 뒤로가기 버튼 자리다. -function ScreenHeaderSkeleton({ - variant, -}: { - variant: ScreenHeaderSkeletonVariant; -}) { +// 상세 화면 ScreenHeader(variant="normal")와 같은 56px 자리에 좌상단 24px 뒤로가기 버튼 자리를 그린다. +// 헤더가 비어 있다가 실제 화면이 들어올 때 본문이 헤더 높이만큼 밀려 내려가지 않도록, 스켈레톤도 헤더 슬롯을 채운다. +function ScreenHeaderSkeleton() { const animation = useSkeletonAnimation(); - if (variant === "normal") { - return ( -
- -
- ); - } - return ( -
+
); } -// 화면 스켈레톤이 헤더 슬롯을 채울 때 쓴다. 실제 화면이 마운트되면 그 화면의 useScreenHeader가 덮어쓴다. -export function useScreenHeaderSkeleton( - variant: ScreenHeaderSkeletonVariant = "display", -) { - useScreenHeader(); +// 헤더가 데이터에 따라 달라지는 화면(공지 상세)의 스켈레톤이 헤더 슬롯을 채울 때 쓴다. +// 데이터가 오면 그 화면의 useScreenHeader가 덮어쓴다. +export function useScreenHeaderSkeleton() { + useScreenHeader(); } // 목록 스켈레톤의 행 key. 행 수만 필요하고 내용이 없어서 미리 만들어 둔다. export const SKELETON_ROW_KEYS = ["row-1", "row-2", "row-3", "row-4", "row-5"]; - -// 화면 JS를 받는 동안의 기본 로딩 화면(router.tsx의 lazyScreen). 본문 모양은 화면마다 달라서 -// 흉내 내지 않고, 헤더 자리만 채운다 — 데이터 영역의 스켈레톤은 화면이 자기 Suspense로 그린다. -function ScreenSkeleton() { - useScreenHeaderSkeleton(); - - return null; -} - -export default ScreenSkeleton; diff --git a/src/features/notices/components/NoticesDetailSkeleton.tsx b/src/features/notices/components/NoticesDetailSkeleton.tsx index 09b5c36..bf61950 100644 --- a/src/features/notices/components/NoticesDetailSkeleton.tsx +++ b/src/features/notices/components/NoticesDetailSkeleton.tsx @@ -10,7 +10,7 @@ import { // 사진 유무는 공지마다 달라서 사진 자리는 그리지 않는다. function NoticesDetailSkeleton() { const animation = useSkeletonAnimation(); - useScreenHeaderSkeleton("normal"); + useScreenHeaderSkeleton(); return (
From 1db4cd95473d80840bd535e65ff6bc494960d648 Mon Sep 17 00:00:00 2001 From: gain <163835628+leegain1@users.noreply.github.com> Date: Thu, 1 Oct 2026 17:08:06 +0900 Subject: [PATCH 12/12] =?UTF-8?q?docs:=20=EC=BD=94=EB=93=9C=20=EB=B6=84?= =?UTF-8?q?=ED=95=A0=20=EC=A0=9C=EA=B1=B0=EC=97=90=20=EB=A7=9E=EC=B6=B0=20?= =?UTF-8?q?=EB=9D=BC=EC=9A=B0=ED=8C=85=20=EC=BB=A8=EB=B2=A4=EC=85=98=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/conventions/coding-style.md | 1 - 1 file changed, 1 deletion(-) diff --git a/docs/conventions/coding-style.md b/docs/conventions/coding-style.md index 8baf1e8..d43ce7e 100644 --- a/docs/conventions/coding-style.md +++ b/docs/conventions/coding-style.md @@ -41,7 +41,6 @@ 새 옵션이 필요하면 `src/app/ScreenLayoutRoute.tsx`의 `ScreenRouteHandle`에 필드를 추가하고, 그 값을 `ScreenLayout` prop으로 넘긴다. 현재 필드는 `hasBottomNav`(하단 탭 표시)와 `background`(375×812 프레임 배경 — 헤더 뒤까지 포함이라 화면 본문에서 칠할 수 없다. 신청 완료처럼 Figma가 흰 배경으로 그린 화면만 `"normal"`)다. - `ScreenLayout`은 **라우터를 모르는 prop 기반 컴포넌트**로 유지한다. 라우트 정보(`useMatches`)는 `ScreenLayoutRoute`만 읽는다. -- 화면 컴포넌트는 `lazyScreen(() => import(...))`으로 라우트마다 코드 분할한다. 화면 JS를 받는 동안에는 헤더 자리만 채우는 `ScreenSkeleton`이 보인다. 상세처럼 화면 전체가 데이터 영역인 화면은 그 화면의 데이터 스켈레톤을 두 번째 인자로 넘겨, JS 로딩과 데이터 로딩이 같은 스켈레톤으로 이어지게 한다(아래 "데이터 로딩" 참고). - 화면 스택을 쌓는 이동(목록→상세, 상세→신청 등)은 `navigate(to, { viewTransition: true })`·``으로 슬라이드 전환을 켠다. 뒤로가기는 react-router가 그 이동을 기억해 반대 방향으로 자동 적용하므로 `navigate(-1)`은 그대로 둔다. 브라우저 앞으로가기도 POP이라, `ScreenLayoutRoute`는 히스토리 위치(`history.state.idx`)가 줄어든 POP만 뒤로 방향으로 본다. Bottom Nav·상단 탭처럼 형제 화면을 오가는 이동과 홈으로 돌아가는 이동은 켜지 않는다(즉시 전환). 애니메이션은 `index.css`, 방향은 `ScreenLayoutRoute`가 정한다. - 라우트가 없는 경로는 레이아웃 안의 `path: "*"` 라우트(`ComingSoonScreen`)가 받는다. 하단 탭이 유지돼서 다른 화면으로 돌아갈 수 있다. 구체적인 경로가 `*`보다 항상 우선하므로 배열 순서는 신경 쓰지 않아도 된다.