- );
-}
-
-// 전용 스켈레톤이 없는 화면(홈·신청 흐름·준비 중 화면)의 기본 로딩 화면. 본문 모양은 화면마다
-// 달라서 흉내 내지 않고, 헤더 자리만 채운다.
+// 화면 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/14] =?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/14] =?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/14] =?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`)가 받는다. 하단 탭이 유지돼서 다른 화면으로 돌아갈 수 있다. 구체적인 경로가 `*`보다 항상 우선하므로 배열 순서는 신경 쓰지 않아도 된다.
From 64668d618d249356c5522fd1d059f9f0d20798f5 Mon Sep 17 00:00:00 2001
From: gain <163835628+leegain1@users.noreply.github.com>
Date: Sat, 3 Oct 2026 17:31:12 +0900
Subject: [PATCH 13/14] =?UTF-8?q?refactor:=20TanStack=20Query=EB=A5=BC=20?=
=?UTF-8?q?=EC=A0=9C=EA=B1=B0=ED=95=98=EA=B3=A0=20=EC=A7=80=EC=97=B0=20moc?=
=?UTF-8?q?kupApi=EC=99=80=20use()=EB=A1=9C=20=EB=8D=B0=EC=9D=B4=ED=84=B0?=
=?UTF-8?q?=20=EB=A1=9C=EB=94=A9?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
package.json | 1 -
pnpm-lock.yaml | 18 ---------------
src/entities/bililge/bililgeApi.ts | 6 ++---
src/entities/bililge/bililgeQueries.ts | 12 ----------
src/entities/events/eventsApi.ts | 11 +++++----
src/entities/events/eventsQueries.ts | 17 --------------
src/entities/notices/noticesApi.ts | 11 +++++----
src/entities/notices/noticesQueries.ts | 17 --------------
.../bililge/components/BililgeItemList.tsx | 6 ++---
.../events/components/EventsDetailContent.tsx | 6 ++---
src/features/events/components/EventsList.tsx | 7 +++---
.../components/NoticesDetailContent.tsx | 6 ++---
.../notices/components/NoticesList.tsx | 7 +++---
src/lib/mockResponse.ts | 12 ----------
src/lib/mockupApi.ts | 23 +++++++++++++++++++
src/main.tsx | 7 +-----
16 files changed, 56 insertions(+), 111 deletions(-)
delete mode 100644 src/entities/bililge/bililgeQueries.ts
delete mode 100644 src/entities/events/eventsQueries.ts
delete mode 100644 src/entities/notices/noticesQueries.ts
delete mode 100644 src/lib/mockResponse.ts
create mode 100644 src/lib/mockupApi.ts
diff --git a/package.json b/package.json
index 9c8b93f..8f3006b 100644
--- a/package.json
+++ b/package.json
@@ -14,7 +14,6 @@
},
"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 28b3a9b..80c978e 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -11,9 +11,6 @@ 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)
@@ -719,14 +716,6 @@ 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==}
@@ -1858,13 +1847,6 @@ 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/entities/bililge/bililgeApi.ts b/src/entities/bililge/bililgeApi.ts
index fa32657..8577a8f 100644
--- a/src/entities/bililge/bililgeApi.ts
+++ b/src/entities/bililge/bililgeApi.ts
@@ -1,8 +1,8 @@
import { BILILGE_ITEMS } from "@/entities/bililge/bililgeMock";
import type { BililgeItem } from "@/entities/bililge/types";
-import { mockResponse } from "@/lib/mockResponse";
+import { mockupApi } from "@/lib/mockupApi";
-// 실 API가 붙으면 함수 안쪽만 요청 코드로 바꾼다 — 화면은 bililgeQueries로만 데이터를 받는다.
+// 실 API가 붙으면 함수 안쪽만 요청 코드로 바꾼다 — 화면은 이 함수로만 데이터를 받는다.
export function fetchBililgeItems(): Promise {
- return mockResponse(BILILGE_ITEMS);
+ return mockupApi("bililge/items", () => BILILGE_ITEMS);
}
diff --git a/src/entities/bililge/bililgeQueries.ts b/src/entities/bililge/bililgeQueries.ts
deleted file mode 100644
index 2078511..0000000
--- a/src/entities/bililge/bililgeQueries.ts
+++ /dev/null
@@ -1,12 +0,0 @@
-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/events/eventsApi.ts b/src/entities/events/eventsApi.ts
index ec392ea..434f203 100644
--- a/src/entities/events/eventsApi.ts
+++ b/src/entities/events/eventsApi.ts
@@ -1,13 +1,16 @@
import { EVENTS } from "@/entities/events/eventsMock";
import type { EventItem } from "@/entities/events/types";
-import { mockResponse } from "@/lib/mockResponse";
+import { mockupApi } from "@/lib/mockupApi";
-// 실 API가 붙으면 함수 안쪽만 요청 코드로 바꾼다 — 화면은 eventsQueries로만 데이터를 받는다.
+// 실 API가 붙으면 함수 안쪽만 요청 코드로 바꾼다 — 화면은 이 함수로만 데이터를 받는다.
export function fetchEvents(): Promise {
- return mockResponse(EVENTS);
+ return mockupApi("events", () => EVENTS);
}
// 없는 행사는 null — 상세 화면이 "행사를 찾을 수 없어요" 빈 상태를 그린다.
export function fetchEvent(eventId: string): Promise {
- return mockResponse(EVENTS.find((event) => event.id === eventId) ?? null);
+ return mockupApi(
+ `events/${eventId}`,
+ () => EVENTS.find((event) => event.id === eventId) ?? null,
+ );
}
diff --git a/src/entities/events/eventsQueries.ts b/src/entities/events/eventsQueries.ts
deleted file mode 100644
index 34afe36..0000000
--- a/src/entities/events/eventsQueries.ts
+++ /dev/null
@@ -1,17 +0,0 @@
-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/notices/noticesApi.ts b/src/entities/notices/noticesApi.ts
index ec4f283..98afb1a 100644
--- a/src/entities/notices/noticesApi.ts
+++ b/src/entities/notices/noticesApi.ts
@@ -1,13 +1,16 @@
import { NOTICES } from "@/entities/notices/noticesMock";
import type { Notice } from "@/entities/notices/types";
-import { mockResponse } from "@/lib/mockResponse";
+import { mockupApi } from "@/lib/mockupApi";
-// 실 API가 붙으면 함수 안쪽만 요청 코드로 바꾼다 — 화면은 noticesQueries로만 데이터를 받는다.
+// 실 API가 붙으면 함수 안쪽만 요청 코드로 바꾼다 — 화면은 이 함수로만 데이터를 받는다.
export function fetchNotices(): Promise {
- return mockResponse(NOTICES);
+ return mockupApi("notices", () => NOTICES);
}
// 없는 공지는 null — 상세 화면이 "존재하지 않는 공지예요"를 그린다.
export function fetchNotice(noticeId: string): Promise {
- return mockResponse(NOTICES.find((notice) => notice.id === noticeId) ?? null);
+ return mockupApi(
+ `notices/${noticeId}`,
+ () => NOTICES.find((notice) => notice.id === noticeId) ?? null,
+ );
}
diff --git a/src/entities/notices/noticesQueries.ts b/src/entities/notices/noticesQueries.ts
deleted file mode 100644
index df6611e..0000000
--- a/src/entities/notices/noticesQueries.ts
+++ /dev/null
@@ -1,17 +0,0 @@
-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/features/bililge/components/BililgeItemList.tsx b/src/features/bililge/components/BililgeItemList.tsx
index 1ba1d20..a0dc1e9 100644
--- a/src/features/bililge/components/BililgeItemList.tsx
+++ b/src/features/bililge/components/BililgeItemList.tsx
@@ -1,6 +1,6 @@
-import { useSuspenseQuery } from "@tanstack/react-query";
+import { use } from "react";
-import { bililgeQueries } from "@/entities/bililge/bililgeQueries";
+import { fetchBililgeItems } from "@/entities/bililge/bililgeApi";
import type { BililgeItem } from "@/entities/bililge/types";
import BililgeItemCard from "@/features/bililge/components/BililgeItemCard";
@@ -11,7 +11,7 @@ interface BililgeItemListProps {
// 대여 탭의 물품 목록 데이터를 받아 그리는 부분. 데이터를 받는 동안은 BililgeListScreen의
// Suspense가 BililgeListSkeleton을 보여준다. 대여 시트를 여는 동작은 Screen이 콜백으로 넘긴다.
function BililgeItemList({ onRentRequest }: BililgeItemListProps) {
- const { data: items } = useSuspenseQuery(bililgeQueries.items());
+ const items = use(fetchBililgeItems());
return (
diff --git a/src/features/events/components/EventsDetailContent.tsx b/src/features/events/components/EventsDetailContent.tsx
index db9dcde..66d4296 100644
--- a/src/features/events/components/EventsDetailContent.tsx
+++ b/src/features/events/components/EventsDetailContent.tsx
@@ -1,4 +1,3 @@
-import { useSuspenseQuery } from "@tanstack/react-query";
import {
ActionArea,
ActionAreaButton,
@@ -7,9 +6,10 @@ import {
Typography,
} from "@wanteddev/wds";
import { IconChevronLeft } from "@wanteddev/wds-icon";
+import { use } from "react";
import PhotoGallery from "@/components/ui/PhotoGallery";
-import { eventsQueries } from "@/entities/events/eventsQueries";
+import { fetchEvent } from "@/entities/events/eventsApi";
import EventsEmptyState from "@/features/events/components/EventsEmptyState";
import EventsStatusBadge from "@/features/events/components/EventsStatusBadge";
@@ -35,7 +35,7 @@ function EventsDetailContent({
onBack,
onApply,
}: EventsDetailContentProps) {
- const { data: event } = useSuspenseQuery(eventsQueries.detail(eventId));
+ const event = use(fetchEvent(eventId));
if (!event) {
return (
diff --git a/src/features/events/components/EventsList.tsx b/src/features/events/components/EventsList.tsx
index 16b68bb..bbc744c 100644
--- a/src/features/events/components/EventsList.tsx
+++ b/src/features/events/components/EventsList.tsx
@@ -1,8 +1,7 @@
-import { useSuspenseQuery } from "@tanstack/react-query";
import { Divider } from "@wanteddev/wds";
-import { Fragment } from "react";
+import { Fragment, use } from "react";
-import { eventsQueries } from "@/entities/events/eventsQueries";
+import { fetchEvents } from "@/entities/events/eventsApi";
import EventsCard from "@/features/events/components/EventsCard";
import EventsEmptyState from "@/features/events/components/EventsEmptyState";
@@ -42,7 +41,7 @@ interface EventsListProps {
// 행사 목록 데이터를 받아 그리는 부분. 데이터를 받는 동안은 EventsListScreen의 Suspense가
// EventsListSkeleton을 보여준다. 이동 같은 화면 동작은 Screen이 콜백으로 넘긴다.
function EventsList({ statusFilter, onSelect, onApply }: EventsListProps) {
- const { data: events } = useSuspenseQuery(eventsQueries.list());
+ const events = use(fetchEvents());
// 빌릴게 카테고리 필터와 달리 모집 상태는 목데이터에 이미 들어있어서 실제로 걸러낼 수 있다.
const visibleEvents =
diff --git a/src/features/notices/components/NoticesDetailContent.tsx b/src/features/notices/components/NoticesDetailContent.tsx
index 9dd1c07..c204d9c 100644
--- a/src/features/notices/components/NoticesDetailContent.tsx
+++ b/src/features/notices/components/NoticesDetailContent.tsx
@@ -1,4 +1,3 @@
-import { useSuspenseQuery } from "@tanstack/react-query";
import {
ContentBadge,
type ThemeColorsToken,
@@ -6,11 +5,12 @@ import {
Typography,
} from "@wanteddev/wds";
import { IconChevronLeft } from "@wanteddev/wds-icon";
+import { use } from "react";
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 { fetchNotice } from "@/entities/notices/noticesApi";
import type { NoticeCategory } from "@/entities/notices/types";
const CATEGORY_BADGE_COLOR: Record = {
@@ -27,7 +27,7 @@ interface NoticesDetailContentProps {
// 공지 데이터를 받아 그리는 부분. 데이터를 받는 동안은 NoticesDetailScreen의 Suspense가
// NoticesDetailSkeleton을 보여준다. 헤더 모양이 공지의 사진 유무로 갈려서 헤더도 여기서 등록한다.
function NoticesDetailContent({ noticeId, onBack }: NoticesDetailContentProps) {
- const { data: notice } = useSuspenseQuery(noticesQueries.detail(noticeId));
+ const notice = use(fetchNotice(noticeId));
const photoCount = notice?.photoCount ?? 0;
const backButton = (
diff --git a/src/features/notices/components/NoticesList.tsx b/src/features/notices/components/NoticesList.tsx
index 0ef1f55..632db93 100644
--- a/src/features/notices/components/NoticesList.tsx
+++ b/src/features/notices/components/NoticesList.tsx
@@ -1,9 +1,8 @@
-import { useSuspenseQuery } from "@tanstack/react-query";
import { Divider } from "@wanteddev/wds";
-import { Fragment } from "react";
+import { Fragment, use } from "react";
import { Link } from "react-router-dom";
-import { noticesQueries } from "@/entities/notices/noticesQueries";
+import { fetchNotices } from "@/entities/notices/noticesApi";
import type { NoticeCategory } from "@/entities/notices/types";
import NoticesCard from "@/features/notices/components/NoticesCard";
@@ -14,7 +13,7 @@ interface NoticesListProps {
// 공지 목록 데이터를 받아 그리는 부분. 데이터를 받는 동안은 NoticesListScreen의 Suspense가
// NoticesListSkeleton을 보여준다.
function NoticesList({ category }: NoticesListProps) {
- const { data } = useSuspenseQuery(noticesQueries.list());
+ const data = use(fetchNotices());
const notices = data.filter(
(notice) => category === "all" || notice.category === category,
);
diff --git a/src/lib/mockResponse.ts b/src/lib/mockResponse.ts
deleted file mode 100644
index 8ca6783..0000000
--- a/src/lib/mockResponse.ts
+++ /dev/null
@@ -1,12 +0,0 @@
-// 실 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/lib/mockupApi.ts b/src/lib/mockupApi.ts
new file mode 100644
index 0000000..17284bd
--- /dev/null
+++ b/src/lib/mockupApi.ts
@@ -0,0 +1,23 @@
+// 실 API가 붙기 전까지 entities의 API 함수가 목데이터를 서버 응답처럼 비동기로 돌려줄 때 쓴다.
+// 개발 서버에서는 화면의 로딩 스켈레톤을 확인할 수 있게 일부러 늦게 응답하고, 배포 빌드에서는 바로 응답한다.
+const MOCK_DELAY_MS = 500;
+
+// 화면은 use()로 이 Promise를 읽는데, 렌더마다 새 Promise를 만들면 매번 다시 서스펜드된다.
+// 그래서 요청(key)마다 Promise를 한 번만 만들어 재사용한다 — 다시 들어온 화면은 스켈레톤 없이 바로 그려진다.
+// 실 API로 바꿀 때는 TanStack Query 같은 서버 상태 라이브러리가 이 캐시 역할을 맡는다.
+const cache = new Map>();
+
+export function mockupApi(key: string, getData: () => T): Promise {
+ const cached = cache.get(key);
+ if (cached) {
+ return cached as Promise;
+ }
+
+ const promise = import.meta.env.DEV
+ ? new Promise((resolve) => {
+ setTimeout(() => resolve(getData()), MOCK_DELAY_MS);
+ })
+ : Promise.resolve(getData());
+ cache.set(key, promise);
+ return promise;
+}
diff --git a/src/main.tsx b/src/main.tsx
index ffa91d5..0bbfd0c 100644
--- a/src/main.tsx
+++ b/src/main.tsx
@@ -1,4 +1,3 @@
-import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ThemeProvider } from "@wanteddev/wds";
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
@@ -7,14 +6,10 @@ import { RouterProvider } from "react-router-dom";
import { router } from "@/app/router";
import "./index.css";
-const queryClient = new QueryClient();
-
createRoot(document.getElementById("root")!).render(
-
-
-
+ ,
);
From 4001a7ddabdb6f9f6146285ea0eef07a26f2ee24 Mon Sep 17 00:00:00 2001
From: gain <163835628+leegain1@users.noreply.github.com>
Date: Sat, 3 Oct 2026 17:31:12 +0900
Subject: [PATCH 14/14] =?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=EC=9D=84=20moc?=
=?UTF-8?q?kupApi=20=EA=B8=B0=EC=A4=80=EC=9C=BC=EB=A1=9C=20=EC=88=98?=
=?UTF-8?q?=EC=A0=95?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
docs/conventions/coding-style.md | 8 ++++----
1 file changed, 4 insertions(+), 4 deletions(-)
diff --git a/docs/conventions/coding-style.md b/docs/conventions/coding-style.md
index d43ce7e..451dea5 100644
--- a/docs/conventions/coding-style.md
+++ b/docs/conventions/coding-style.md
@@ -46,14 +46,14 @@
## 데이터 로딩
-- 서버 데이터는 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으로 받는다.
+- 서버 데이터를 받는 API 함수와 타입은 `entities/<도메인>/`에 둔다(`<도메인>Api.ts`·`types.ts`). 화면은 데이터 컴포넌트에서 `use(fetchEvents())`처럼 API 함수의 Promise를 React `use()`로 읽는다. 서버 상태 라이브러리(TanStack Query)는 실 API를 연동할 때 도입하고, 그때 `use(...)`를 `useSuspenseQuery`로 바꾼다.
+- 실 API 전까지 API 함수는 `<도메인>Mock.ts`의 목데이터를 `mockupApi(key, () => data)`(`lib/mockupApi.ts`)로 돌려준다. 개발 서버에서는 스켈레톤을 확인할 수 있게 500ms 늦게 응답하고, 배포 빌드에서는 바로 응답한다. 같은 key의 Promise는 재사용되므로(렌더마다 새로 만들면 `use()`가 계속 서스펜드된다) key는 요청마다 겹치지 않게 `"events"`, `` `events/${id}` ``처럼 정한다. API가 붙으면 API 함수 안쪽만 바꾼다.
+- 화면(`<화면>Screen.tsx`)은 헤더 등록, UI 상태, 이동 같은 동작을 맡고, 데이터를 받는 영역만 `}>`으로 감싼다. `use()`로 데이터를 읽고 그리는 부분은 `features/<기능>/components/`의 컴포넌트(`EventsList`, `EventsDetailContent` 등)로 분리하고, 이동 같은 동작은 콜백 prop으로 받는다.
- 데이터와 무관한 헤더·탭·필터는 Suspense 밖에서 바로 그리고, 스켈레톤은 데이터 영역의 배치만 따라 그린다. 전용 스켈레톤은 `features/<기능>/components/<화면>Skeleton.tsx`에 두고 WDS `Skeleton`으로 그린다. 헤더가 데이터에 따라 달라지는 화면(공지 상세)은 헤더도 데이터 컴포넌트가 등록하고, 스켈레톤이 `useScreenHeaderSkeleton`으로 헤더 자리를 채운다.
## 에러 / 비동기
-- async는 try/catch 또는 TanStack Query의 에러 상태로 다룬다. **빈 catch 금지**.
+- async는 try/catch 또는 서버 상태 라이브러리(도입 시)의 에러 상태로 다룬다. **빈 catch 금지**.
- 사용자에게 보이는 메시지와 개발 로깅을 구분한다.
## 주석