- );
-}
-
-// 전용 스켈레톤이 없는 화면(홈·신청 흐름·준비 중 화면)의 기본 로딩 화면. 본문 모양은 화면마다
-// 달라서 흉내 내지 않고, 헤더 자리만 채운다.
+// 화면 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 (
+
+ );
+}
+
+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"
- >
-
-
-
-
-
-
- {/* 본문은 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로 가운데 정렬한다.
-
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). */}
+
+
+
+
+
+
+
+
+ {/* 본문은 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로 가운데 정렬한다.
+
+ );
+}
+
+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 (
-
+ );
+}
+
+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 (
-
);
}
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`)가 받는다. 하단 탭이 유지돼서 다른 화면으로 돌아갈 수 있다. 구체적인 경로가 `*`보다 항상 우선하므로 배열 순서는 신경 쓰지 않아도 된다.