{/* 화면 전용 배경 포털 대상 — 프레임 안에서 가장 먼저(맨 아래) 그려져서, 투명한
헤더(예: 챗봇 진입 화면)까지 자연스럽게 비쳐 보인다. 콘텐츠가 없는 화면에서는
diff --git a/src/components/ui/ScreenSkeleton.tsx b/src/components/ui/ScreenSkeleton.tsx
new file mode 100644
index 0000000..221f0ba
--- /dev/null
+++ b/src/components/ui/ScreenSkeleton.tsx
@@ -0,0 +1,36 @@
+import { Skeleton } from "@wanteddev/wds";
+
+import { useScreenHeader } from "@/components/ui/useScreenHeader";
+import { usePrefersReducedMotion } from "@/hooks/usePrefersReducedMotion";
+
+// WDS Skeleton의 깜빡임(2초 pulse)은 JS prop으로만 끌 수 있어서, 스켈레톤마다 이 값으로 넘긴다.
+export function useSkeletonAnimation() {
+ return !usePrefersReducedMotion();
+}
+
+// 상세 화면 ScreenHeader(variant="normal")와 같은 56px 자리에 좌상단 24px 뒤로가기 버튼 자리를 그린다.
+// 헤더가 비어 있다가 실제 화면이 들어올 때 본문이 헤더 높이만큼 밀려 내려가지 않도록, 스켈레톤도 헤더 슬롯을 채운다.
+function ScreenHeaderSkeleton() {
+ const animation = useSkeletonAnimation();
+
+ return (
+
+
+
+ );
+}
+
+// 헤더가 데이터에 따라 달라지는 화면(공지 상세)의 스켈레톤이 헤더 슬롯을 채울 때 쓴다.
+// 데이터가 오면 그 화면의 useScreenHeader가 덮어쓴다.
+export function useScreenHeaderSkeleton() {
+ useScreenHeader(
);
+}
+
+// 목록 스켈레톤의 행 key. 행 수만 필요하고 내용이 없어서 미리 만들어 둔다.
+export const SKELETON_ROW_KEYS = ["row-1", "row-2", "row-3", "row-4", "row-5"];
diff --git a/src/entities/bililge/bililgeApi.ts b/src/entities/bililge/bililgeApi.ts
new file mode 100644
index 0000000..fa32657
--- /dev/null
+++ b/src/entities/bililge/bililgeApi.ts
@@ -0,0 +1,8 @@
+import { BILILGE_ITEMS } from "@/entities/bililge/bililgeMock";
+import type { BililgeItem } from "@/entities/bililge/types";
+import { mockResponse } from "@/lib/mockResponse";
+
+// 실 API가 붙으면 함수 안쪽만 요청 코드로 바꾼다 — 화면은 bililgeQueries로만 데이터를 받는다.
+export function fetchBililgeItems(): Promise
{
+ return mockResponse(BILILGE_ITEMS);
+}
diff --git a/src/features/bililge/constants/bililgeItems.ts b/src/entities/bililge/bililgeMock.ts
similarity index 96%
rename from src/features/bililge/constants/bililgeItems.ts
rename to src/entities/bililge/bililgeMock.ts
index 4f2f477..537161a 100644
--- a/src/features/bililge/constants/bililgeItems.ts
+++ b/src/entities/bililge/bililgeMock.ts
@@ -13,13 +13,7 @@ import powerBank from "@/assets/icons/bililge-items/power-bank.svg";
import sanitaryPad from "@/assets/icons/bililge-items/sanitary-pad.svg";
import umbrella from "@/assets/icons/bililge-items/umbrella.svg";
import usbCCharger from "@/assets/icons/bililge-items/usb-c-charger.svg";
-
-export interface BililgeItem {
- id: string;
- name: string;
- quantity: number;
- icon: string;
-}
+import type { BililgeItem } from "@/entities/bililge/types";
// Figma: 빌릴게 Item Grid (nodeId 1243:73343) 순서·물품명·수량을 그대로 옮긴 목데이터 — 실 API 연동 전까지 사용
export const BILILGE_ITEMS: BililgeItem[] = [
diff --git a/src/entities/bililge/bililgeQueries.ts b/src/entities/bililge/bililgeQueries.ts
new file mode 100644
index 0000000..2078511
--- /dev/null
+++ b/src/entities/bililge/bililgeQueries.ts
@@ -0,0 +1,12 @@
+import { queryOptions } from "@tanstack/react-query";
+
+import { fetchBililgeItems } from "@/entities/bililge/bililgeApi";
+
+// 쿼리 키와 요청 함수를 한곳에 묶어 둔다. 화면은 useSuspenseQuery(bililgeQueries.items())처럼 쓴다.
+export const bililgeQueries = {
+ items: () =>
+ queryOptions({
+ queryFn: fetchBililgeItems,
+ queryKey: ["bililge", "items"],
+ }),
+};
diff --git a/src/entities/bililge/types.ts b/src/entities/bililge/types.ts
new file mode 100644
index 0000000..c02d71d
--- /dev/null
+++ b/src/entities/bililge/types.ts
@@ -0,0 +1,6 @@
+export interface BililgeItem {
+ id: string;
+ name: string;
+ quantity: number;
+ icon: string;
+}
diff --git a/src/entities/events/eventsApi.ts b/src/entities/events/eventsApi.ts
new file mode 100644
index 0000000..ec392ea
--- /dev/null
+++ b/src/entities/events/eventsApi.ts
@@ -0,0 +1,13 @@
+import { EVENTS } from "@/entities/events/eventsMock";
+import type { EventItem } from "@/entities/events/types";
+import { mockResponse } from "@/lib/mockResponse";
+
+// 실 API가 붙으면 함수 안쪽만 요청 코드로 바꾼다 — 화면은 eventsQueries로만 데이터를 받는다.
+export function fetchEvents(): Promise {
+ return mockResponse(EVENTS);
+}
+
+// 없는 행사는 null — 상세 화면이 "행사를 찾을 수 없어요" 빈 상태를 그린다.
+export function fetchEvent(eventId: string): Promise {
+ return mockResponse(EVENTS.find((event) => event.id === eventId) ?? null);
+}
diff --git a/src/entities/events/eventsMock.ts b/src/entities/events/eventsMock.ts
new file mode 100644
index 0000000..653f22c
--- /dev/null
+++ b/src/entities/events/eventsMock.ts
@@ -0,0 +1,71 @@
+import type { EventItem } from "@/entities/events/types";
+
+// Figma 상세(1133:42433 모집중 / 1156:53992 모집예정)의 메타데이터·본문은 목업이라 행사별로 다르지 않다.
+// 목록 카드가 같은 행사명 4개를 상태만 바꿔 보여주는 것과 같은 이유로, 상세 내용도 공통 상수로 두고
+// 상태별로 갈리는 값(뱃지 문구, CTA 문구)만 항목마다 다르게 준다.
+const MOCK_SCHEDULE = "4월 15일 (수) 18:30 ~";
+const MOCK_LOCATION = "미래관 4층 신관 입구 (예대 방면)";
+const MOCK_AUDIENCE = ["소프트웨어융합대학 과학생회비 납부자", "선착순 150명"];
+const MOCK_DESCRIPTION = `안녕하십니까, 제10대 소프트웨어융합대학 학생회 ‘에코’입니다.
+
+기말고사를 준비하고 계신 학우 여러분을 응원하기 위해 간식행사를 진행합니다 🍱✨
+
+시험기간 동안 든든하게 힘내시길 바라며, 많은 관심과 참여 부탁드립니다!
+
+📌 간식행사 일정
+▪️ 일시 : 6월 1일 (월) 11:00 ~
+▪️ 장소 : 미래관 4층 신관 입구 (예대방면)
+
+📌 대상
+▪️ 소프트웨어융합대학 재학생 선착순 180명
+※ 과학생회비 미납부자 참여 가능
+
+📌 간식행사 메뉴
+▪️ 돈까스 도련님 도시락
+▪️ 나랑드사이다 제로
+
+📌 유의 사항
+▪️ 소프트웨어융합대학 학생임을 증명할 수 있는 모바일 학생증 혹은 실물 학생증을 지참해주시기 바랍니다.
+▪️ 1인당 1세트만 수령 가능하며, 선착순 수량 소진 시 수령이 불가능합니다.
+
+많은 학우 여러분의 관심과 참여 부탁드립니다.
+감사합니다 😊`;
+
+// Figma: 행사 Event List (nodeId 1243:70866) 문구를 그대로 옮긴 목데이터 — 실 API 연동 전까지 사용.
+//
+// Figma 목업은 같은 행사명 4개(모집중 1 / 모집예정 1 / 모집종료 2)를 상태만 바꿔 보여주는데,
+// 여기서는 모집중을 비우고 모집종료 중복도 하나로 줄였다(모집예정 1 / 모집종료 1).
+//
+// Empty State(1165:62713)는 Figma가 "모집중" 필터 버전으로만 그려져 있고, 일러스트·문구와
+// "아카이빙 둘러보기" 버튼이 그 조합의 스펙이다. 목데이터에 모집중 항목이 있으면 이 화면을
+// 아예 볼 수 없어서 모집중을 비웠다. 대신 모집중 상세(1133:42433)는 카드로 진입할 수 없다 —
+// 실 API가 붙으면 사라질 제약이고, 지금 확인이 필요하면 아래 항목 하나의 status를 "open"으로
+// 되돌리면 된다. 모집예정은 상세 디자인(1156:53992)이 있어 진입 가능하게 남겼다.
+export const EVENTS: EventItem[] = [
+ {
+ actionLabel: "모집종료",
+ audience: MOCK_AUDIENCE,
+ description: MOCK_DESCRIPTION,
+ eventDate: "행사일 2026.06.04",
+ id: "sw-sports-day-closed",
+ imageCount: 7,
+ location: MOCK_LOCATION,
+ schedule: MOCK_SCHEDULE,
+ status: "closed",
+ statusLabel: "모집종료",
+ title: "소프트웨어융합대학 체육대회",
+ },
+ {
+ actionLabel: "8월 10일 오픈",
+ audience: MOCK_AUDIENCE,
+ description: MOCK_DESCRIPTION,
+ eventDate: "행사일 2026.06.04",
+ id: "sw-sports-day-upcoming",
+ imageCount: 7,
+ location: MOCK_LOCATION,
+ schedule: MOCK_SCHEDULE,
+ status: "upcoming",
+ statusLabel: "모집예정",
+ title: "소프트웨어융합대학 체육대회",
+ },
+];
diff --git a/src/entities/events/eventsQueries.ts b/src/entities/events/eventsQueries.ts
new file mode 100644
index 0000000..34afe36
--- /dev/null
+++ b/src/entities/events/eventsQueries.ts
@@ -0,0 +1,17 @@
+import { queryOptions } from "@tanstack/react-query";
+
+import { fetchEvent, fetchEvents } from "@/entities/events/eventsApi";
+
+// 쿼리 키와 요청 함수를 한곳에 묶어 둔다. 화면은 useSuspenseQuery(eventsQueries.list())처럼 쓴다.
+export const eventsQueries = {
+ detail: (eventId: string) =>
+ queryOptions({
+ queryFn: () => fetchEvent(eventId),
+ queryKey: ["events", eventId],
+ }),
+ list: () =>
+ queryOptions({
+ queryFn: fetchEvents,
+ queryKey: ["events"],
+ }),
+};
diff --git a/src/entities/events/types.ts b/src/entities/events/types.ts
new file mode 100644
index 0000000..140d997
--- /dev/null
+++ b/src/entities/events/types.ts
@@ -0,0 +1,20 @@
+export type EventStatus = "open" | "upcoming" | "closed";
+
+export interface EventItem {
+ id: string;
+ title: string;
+ eventDate: string;
+ status: EventStatus;
+ statusLabel: string;
+ actionLabel: string;
+ /** 상세 상단 Hero 이미지 개수 — 실 이미지 API 전까지 PageCounter 표기용 */
+ imageCount: number;
+ /** 상세 메타데이터 "일시" */
+ schedule: string;
+ /** 상세 메타데이터 "장소" */
+ location: string;
+ /** 상세 메타데이터 "대상" — Figma가 두 줄로 쪼개 보여줘서 줄 단위로 들고 있는다 */
+ audience: string[];
+ /** 상세 본문. 줄바꿈을 그대로 살려 렌더링한다 */
+ description: string;
+}
diff --git a/src/entities/notices/noticesApi.ts b/src/entities/notices/noticesApi.ts
new file mode 100644
index 0000000..ec4f283
--- /dev/null
+++ b/src/entities/notices/noticesApi.ts
@@ -0,0 +1,13 @@
+import { NOTICES } from "@/entities/notices/noticesMock";
+import type { Notice } from "@/entities/notices/types";
+import { mockResponse } from "@/lib/mockResponse";
+
+// 실 API가 붙으면 함수 안쪽만 요청 코드로 바꾼다 — 화면은 noticesQueries로만 데이터를 받는다.
+export function fetchNotices(): Promise {
+ return mockResponse(NOTICES);
+}
+
+// 없는 공지는 null — 상세 화면이 "존재하지 않는 공지예요"를 그린다.
+export function fetchNotice(noticeId: string): Promise {
+ return mockResponse(NOTICES.find((notice) => notice.id === noticeId) ?? null);
+}
diff --git a/src/features/notices/constants/notices.ts b/src/entities/notices/noticesMock.ts
similarity index 86%
rename from src/features/notices/constants/notices.ts
rename to src/entities/notices/noticesMock.ts
index fc61f53..fa1ce56 100644
--- a/src/features/notices/constants/notices.ts
+++ b/src/entities/notices/noticesMock.ts
@@ -1,16 +1,4 @@
-export type NoticeCategory = "일반" | "제휴";
-
-export interface Notice {
- id: string;
- title: string;
- date: string;
- category: NoticeCategory;
- isPinned?: boolean;
- hasThumbnail?: boolean;
- /** 상세 화면 이미지 갤러리 총 장수 — hasThumbnail일 때만 의미가 있다. */
- photoCount?: number;
- body: string;
-}
+import type { Notice } from "@/entities/notices/types";
// Figma: 공지 상세 (nodeId 1256:81842) 목업 본문 — 목업 데이터라 공지 5건이 본문을 공유한다.
const NOTICE_BODY = `안녕하십니까, 제10대 소프트웨어융합대학 학생회 '에코'입니다.
diff --git a/src/entities/notices/noticesQueries.ts b/src/entities/notices/noticesQueries.ts
new file mode 100644
index 0000000..df6611e
--- /dev/null
+++ b/src/entities/notices/noticesQueries.ts
@@ -0,0 +1,17 @@
+import { queryOptions } from "@tanstack/react-query";
+
+import { fetchNotice, fetchNotices } from "@/entities/notices/noticesApi";
+
+// 쿼리 키와 요청 함수를 한곳에 묶어 둔다. 화면은 useSuspenseQuery(noticesQueries.list())처럼 쓴다.
+export const noticesQueries = {
+ detail: (noticeId: string) =>
+ queryOptions({
+ queryFn: () => fetchNotice(noticeId),
+ queryKey: ["notices", noticeId],
+ }),
+ list: () =>
+ queryOptions({
+ queryFn: fetchNotices,
+ queryKey: ["notices"],
+ }),
+};
diff --git a/src/entities/notices/types.ts b/src/entities/notices/types.ts
new file mode 100644
index 0000000..4419ef1
--- /dev/null
+++ b/src/entities/notices/types.ts
@@ -0,0 +1,13 @@
+export type NoticeCategory = "일반" | "제휴";
+
+export interface Notice {
+ id: string;
+ title: string;
+ date: string;
+ category: NoticeCategory;
+ isPinned?: boolean;
+ hasThumbnail?: boolean;
+ /** 상세 화면 이미지 갤러리 총 장수 — hasThumbnail일 때만 의미가 있다. */
+ photoCount?: number;
+ body: string;
+}
diff --git a/src/features/bililge/BililgeListScreen.tsx b/src/features/bililge/BililgeListScreen.tsx
index 9408187..527d90e 100644
--- a/src/features/bililge/BililgeListScreen.tsx
+++ b/src/features/bililge/BililgeListScreen.tsx
@@ -4,18 +4,16 @@ import {
TopNavigationButton,
} from "@wanteddev/wds";
import { IconBell, IconSearch } from "@wanteddev/wds-icon";
-import { startTransition, useState } from "react";
+import { Suspense, startTransition, useState } from "react";
import ScreenHeader from "@/components/ui/ScreenHeader";
import { useScreenHeader } from "@/components/ui/useScreenHeader";
+import type { BililgeItem } from "@/entities/bililge/types";
import BililgeCategoryFilter from "@/features/bililge/components/BililgeCategoryFilter";
-import BililgeItemCard from "@/features/bililge/components/BililgeItemCard";
+import BililgeItemList from "@/features/bililge/components/BililgeItemList";
+import BililgeListSkeleton from "@/features/bililge/components/BililgeListSkeleton";
import BililgeRentalSheet from "@/features/bililge/components/BililgeRentalSheet";
import BililgeReturnSection from "@/features/bililge/components/BililgeReturnSection";
-import {
- BILILGE_ITEMS,
- type BililgeItem,
-} from "@/features/bililge/constants/bililgeItems";
// Figma: 빌릴게 (nodeId 1243:73331)
function BililgeListScreen() {
@@ -62,26 +60,21 @@ function BililgeListScreen() {
{tab === "rent" ? (
-
- {BILILGE_ITEMS.map((item) => (
- {
- // 바텀시트를 여는 것(슬라이드 애니메이션)은 즉시 반영하고, 그 안의 휠
- // 피커(특히 분 60개) 마운트처럼 무거운 작업은 startTransition으로 낮은
- // 우선순위로 미뤄서 첫 프레임이 버벅이지 않게 한다 — 처음 열 때만 해당하고,
- // rentalItem은 닫아도 null로 안 돌아가서 두 번째부터는 이 마운트 비용 자체가 없다.
- setRentalSheetOpen(true);
- startTransition(() => {
- setRentalItem(item);
- });
- }}
- subtitle={`수량 ${item.quantity}`}
- />
- ))}
-
+ // 헤더·토글·필터는 데이터와 무관해서 바로 그리고, 데이터를 받는 목록 자리만 스켈레톤으로 채운다
+
}>
+
{
+ // 바텀시트를 여는 것(슬라이드 애니메이션)은 즉시 반영하고, 그 안의 휠
+ // 피커(특히 분 60개) 마운트처럼 무거운 작업은 startTransition으로 낮은
+ // 우선순위로 미뤄서 첫 프레임이 버벅이지 않게 한다 — 처음 열 때만 해당하고,
+ // rentalItem은 닫아도 null로 안 돌아가서 두 번째부터는 이 마운트 비용 자체가 없다.
+ setRentalSheetOpen(true);
+ startTransition(() => {
+ setRentalItem(item);
+ });
+ }}
+ />
+
) : (
setTab("rent")} />
)}
diff --git a/src/features/bililge/components/BililgeItemList.tsx b/src/features/bililge/components/BililgeItemList.tsx
new file mode 100644
index 0000000..1ba1d20
--- /dev/null
+++ b/src/features/bililge/components/BililgeItemList.tsx
@@ -0,0 +1,31 @@
+import { useSuspenseQuery } from "@tanstack/react-query";
+
+import { bililgeQueries } from "@/entities/bililge/bililgeQueries";
+import type { BililgeItem } from "@/entities/bililge/types";
+import BililgeItemCard from "@/features/bililge/components/BililgeItemCard";
+
+interface BililgeItemListProps {
+ onRentRequest: (item: BililgeItem) => void;
+}
+
+// 대여 탭의 물품 목록 데이터를 받아 그리는 부분. 데이터를 받는 동안은 BililgeListScreen의
+// Suspense가 BililgeListSkeleton을 보여준다. 대여 시트를 여는 동작은 Screen이 콜백으로 넘긴다.
+function BililgeItemList({ onRentRequest }: BililgeItemListProps) {
+ const { data: items } = useSuspenseQuery(bililgeQueries.items());
+
+ return (
+
+ {items.map((item) => (
+ onRentRequest(item)}
+ subtitle={`수량 ${item.quantity}`}
+ />
+ ))}
+
+ );
+}
+
+export default BililgeItemList;
diff --git a/src/features/bililge/components/BililgeListSkeleton.tsx b/src/features/bililge/components/BililgeListSkeleton.tsx
new file mode 100644
index 0000000..7d864e8
--- /dev/null
+++ b/src/features/bililge/components/BililgeListSkeleton.tsx
@@ -0,0 +1,57 @@
+import { Skeleton } from "@wanteddev/wds";
+
+import {
+ SKELETON_ROW_KEYS,
+ useSkeletonAnimation,
+} from "@/components/ui/ScreenSkeleton";
+
+// 대여 물품 데이터를 받는 동안 BililgeListScreen의 목록 자리에 보이는 스켈레톤 — 헤더·토글·필터는
+// 화면이 바로 그리므로 물품 카드(BililgeItemCard) 배치만 따른다. 카드는 흰 면이라 회색 화면 배경 위에
+// 실제 카드와 같은 흰 박스를 깔아 둔다.
+function BililgeListSkeleton() {
+ const animation = useSkeletonAnimation();
+
+ return (
+
+ {SKELETON_ROW_KEYS.map((key) => (
+
+ ))}
+
+ );
+}
+
+export default BililgeListSkeleton;
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/EventsApplicationScreen.tsx b/src/features/events/EventsApplicationScreen.tsx
index 188aeab..a2c9c1b 100644
--- a/src/features/events/EventsApplicationScreen.tsx
+++ b/src/features/events/EventsApplicationScreen.tsx
@@ -96,7 +96,10 @@ function EventsApplicationScreen() {
}
// 제출이 끝난 폼으로는 돌아갈 수 없어야 해서 히스토리를 남기지 않고 바꿔치운다
const resultPath = result === "closed" ? "closed" : "complete";
- navigate(`/events/${eventId}/apply/${resultPath}`, { replace: true });
+ navigate(`/events/${eventId}/apply/${resultPath}`, {
+ replace: true,
+ viewTransition: true,
+ });
};
useScreenHeader(
diff --git a/src/features/events/EventsDetailScreen.tsx b/src/features/events/EventsDetailScreen.tsx
index d407a92..cd4ea7c 100644
--- a/src/features/events/EventsDetailScreen.tsx
+++ b/src/features/events/EventsDetailScreen.tsx
@@ -1,181 +1,25 @@
-import {
- ActionArea,
- ActionAreaButton,
- Divider,
- TopNavigationButton,
- Typography,
-} from "@wanteddev/wds";
-import { IconChevronLeft } from "@wanteddev/wds-icon";
+import { Suspense } from "react";
import { useNavigate, useParams } from "react-router-dom";
-import PhotoGallery from "@/components/ui/PhotoGallery";
-import EventsEmptyState from "@/features/events/components/EventsEmptyState";
-import EventsStatusBadge from "@/features/events/components/EventsStatusBadge";
-import { EVENTS } from "@/features/events/constants/events";
+import EventsDetailContent from "@/features/events/components/EventsDetailContent";
+import EventsDetailSkeleton from "@/features/events/components/EventsDetailSkeleton";
-// Figma: 행사 상세 (nodeId 1133:42433 모집중 / 1156:53992 모집예정)
-//
-// 헤더를 useScreenHeader로 등록하지 않는다 — Figma는 뒤로가기 버튼이 Hero 이미지 위에 떠 있는
-// 오버레이인데 ScreenLayout의 헤더 슬롯은 본문 위에 자리를 차지하는 구조라 그대로는 못 맞춘다.
-// 훅을 호출하지 않으면 슬롯이 null(0px)로 남아서, Hero가 화면 최상단부터 시작한다.
-// 공용 ScreenHeader에 overlay 옵션을 넣는 방안도 검토했지만 다른 화면에 영향이 가서 로컬로 뒀다.
-//
-// 상태별로 갈리는 건 뱃지와 하단 CTA뿐이다 — 모집중만 활성이고 나머지는 disabled에
-// 목데이터의 actionLabel("8월 10일 오픈" / "모집종료")이 그대로 들어간다(목록 카드와 같은 규칙).
+// 행사 상세 화면 — 이동 같은 화면 동작만 정하고, 행사 데이터를 받는 동안은 상세 배치를 따른
+// 스켈레톤을 보여준다. 헤더가 없는 화면이라(EventsDetailContent 주석 참고) 화면 전체가 데이터 영역이다.
function EventsDetailScreen() {
const navigate = useNavigate();
- const { eventId } = useParams<{ eventId: string }>();
- const event = EVENTS.find((item) => item.id === eventId);
-
- if (!event) {
- return (
-
-
-
- );
- }
-
- const isOpen = event.status === "open";
- const hasMultipleImages = event.imageCount > 1;
+ const { eventId = "" } = useParams<{ eventId: string }>();
return (
- // Figma 상세 루트 배경은 Background/Normal/Normal(#FFFFFF)이다 — ScreenLayout 기본
- // 배경(Background/Normal/Alternative, #F7F7F8)과 다르다. 흰 배경이어야 Action Area의
- // 흰 그라데이션이 본문으로 자연스럽게 사라진다(회색 위에 깔면 경계선으로 보인다).
- //
- // Action Area는 Figma처럼 absolute로 본문을 덮는다. 아래 형제로 두면 스크롤 영역이
- // Action Area 위에서 끝나 버려서, 글이 그 아래로 흘러 들어가며 사라지는 효과가 안 난다.
-
-
- {/* Hero — 실제 행사 이미지 API 전까지 Figma와 같은 단색 placeholder.
- Figma는 375×375 정사각이라 폭이 유동인 지금 레이아웃에서는 aspect-square로 둔다.
- 이미지가 여러 장이면 가로 스크롤 스냅으로 한 장씩 넘긴다(공지 상세와 같은 PhotoGallery). */}
-
- navigate(-1)}
- variant="icon"
- >
-
-
-
- }
- photoCount={event.imageCount}
- showCounter={hasMultipleImages}
- slideClassName="aspect-square"
- />
-
-
-
-
-
-
- {event.title}
-
-
-
- {/* Figma는 라벨 칼럼(28px)과 값 칼럼을 gap 12로 나란히 두고, 각 칼럼 안은 gap 8이다 */}
-
-
- {["일시", "장소", "대상"].map((label) => (
-
- {label}
-
- ))}
-
-
-
- {event.schedule}
-
-
- {event.location}
-
- {/* 대상만 여러 줄 — Figma도 한 값 안에서 줄을 나눠 놨다 */}
-
- {event.audience.map((line) => (
-
- {line}
-
- ))}
-
-
-
-
-
-
-
- {/* 본문은 Figma "Label 1/Reading - Regular"(14px, line-height 1.571) = label1-reading.
- 목데이터가 줄바꿈을 그대로 들고 있어서 whitespace-pre-wrap으로 살린다. */}
-
- {event.description}
-
-
-
- {/* Action Area가 본문을 덮으므로 끝부분이 영구히 가리지 않도록 같은 높이를 비워둔다.
- 96px = Action Area 위아래 padding 20+20 + 버튼 56. 데스크톱 프레임에서는
- 홈 인디케이터 여백 14px이 더 붙는다(아래 filler와 같은 규칙). */}
-
-
-
-
-
- {/* WDS ActionAreaButton은 항상 Button size="large"(padding 12px 28px → 48px)로 그리는데,
- Figma Main Action은 padding 16px 28px(56px)이라 세로 padding만 sx로 맞춘다. */}
- navigate(`/events/${event.id}/apply`)}
- 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 c9ac9ad..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,37 +62,19 @@ function EventsListScreen() {
- {tab === "event" && visibleEvents.length === 0 ? (
- // Figma는 Empty State를 목록 영역(헤더·Bottom Nav 사이) 가운데에 둔다.
- // 카드 목록과 같은 gap-6 래퍼 안에 넣으면 flex-1이 높이를 못 받아 위에 붙어버려서,
- // 빈 목록일 때는 래퍼를 대체해 h-full로 가운데 정렬한다.
-
- }>
+
+ navigate(`/events/${eventId}/apply`, { viewTransition: true })
+ }
+ onSelect={(eventId) =>
+ navigate(`/events/${eventId}`, { viewTransition: true })
+ }
+ statusFilter={statusFilter}
/>
-
- ) : tab === "event" ? (
-
- {visibleEvents.map((event, index) => (
-
- {index > 0 && (
-
- )}
- navigate(`/events/${event.id}/apply`)}
- onSelect={() => navigate(`/events/${event.id}`)}
- status={event.status}
- statusLabel={event.statusLabel}
- title={event.title}
- />
-
- ))}
-
+
) : (
void;
+ onApply: (eventId: string) => void;
+}
+
+// Figma: 행사 상세 (nodeId 1133:42433 모집중 / 1156:53992 모집예정)
+// 행사 데이터를 받아 그리는 부분. 데이터를 받는 동안은 EventsDetailScreen의 Suspense가
+// EventsDetailSkeleton을 보여준다. 이동 같은 화면 동작은 Screen이 콜백으로 넘긴다.
+//
+// 헤더를 useScreenHeader로 등록하지 않는다 — Figma는 뒤로가기 버튼이 Hero 이미지 위에 떠 있는
+// 오버레이인데 ScreenLayout의 헤더 슬롯은 본문 위에 자리를 차지하는 구조라 그대로는 못 맞춘다.
+// 훅을 호출하지 않으면 슬롯이 null(0px)로 남아서, Hero가 화면 최상단부터 시작한다.
+// 공용 ScreenHeader에 overlay 옵션을 넣는 방안도 검토했지만 다른 화면에 영향이 가서 로컬로 뒀다.
+//
+// 상태별로 갈리는 건 뱃지와 하단 CTA뿐이다 — 모집중만 활성이고 나머지는 disabled에
+// 목데이터의 actionLabel("8월 10일 오픈" / "모집종료")이 그대로 들어간다(목록 카드와 같은 규칙).
+function EventsDetailContent({
+ eventId,
+ onBack,
+ onApply,
+}: EventsDetailContentProps) {
+ const { data: event } = useSuspenseQuery(eventsQueries.detail(eventId));
+
+ if (!event) {
+ return (
+
+
+
+ );
+ }
+
+ const isOpen = event.status === "open";
+ const hasMultipleImages = event.imageCount > 1;
+
+ return (
+ // Figma 상세 루트 배경은 Background/Normal/Normal(#FFFFFF)이다 — ScreenLayout 기본
+ // 배경(Background/Normal/Alternative, #F7F7F8)과 다르다. 흰 배경이어야 Action Area의
+ // 흰 그라데이션이 본문으로 자연스럽게 사라진다(회색 위에 깔면 경계선으로 보인다).
+ //
+ // Action Area는 Figma처럼 absolute로 본문을 덮는다. 아래 형제로 두면 스크롤 영역이
+ // Action Area 위에서 끝나 버려서, 글이 그 아래로 흘러 들어가며 사라지는 효과가 안 난다.
+
+
+ {/* Hero — 실제 행사 이미지 API 전까지 Figma와 같은 단색 placeholder.
+ Figma는 375×375 정사각이라 폭이 유동인 지금 레이아웃에서는 aspect-square로 둔다.
+ 이미지가 여러 장이면 가로 스크롤 스냅으로 한 장씩 넘긴다(공지 상세와 같은 PhotoGallery). */}
+
+
+
+
+
+ }
+ photoCount={event.imageCount}
+ showCounter={hasMultipleImages}
+ slideClassName="aspect-square"
+ />
+
+
+
+
+
+
+ {event.title}
+
+
+
+ {/* Figma는 라벨 칼럼(28px)과 값 칼럼을 gap 12로 나란히 두고, 각 칼럼 안은 gap 8이다 */}
+
+
+ {["일시", "장소", "대상"].map((label) => (
+
+ {label}
+
+ ))}
+
+
+
+ {event.schedule}
+
+
+ {event.location}
+
+ {/* 대상만 여러 줄 — Figma도 한 값 안에서 줄을 나눠 놨다 */}
+
+ {event.audience.map((line) => (
+
+ {line}
+
+ ))}
+
+
+
+
+
+
+
+ {/* 본문은 Figma "Label 1/Reading - Regular"(14px, line-height 1.571) = label1-reading.
+ 목데이터가 줄바꿈을 그대로 들고 있어서 whitespace-pre-wrap으로 살린다. */}
+
+ {event.description}
+
+
+
+ {/* Action Area가 본문을 덮으므로 끝부분이 영구히 가리지 않도록 같은 높이를 비워둔다.
+ 96px = Action Area 위아래 padding 20+20 + 버튼 56. 데스크톱 프레임에서는
+ 홈 인디케이터 여백 14px이 더 붙는다(아래 filler와 같은 규칙). */}
+
+
+
+
+
+ {/* WDS ActionAreaButton은 항상 Button size="large"(padding 12px 28px → 48px)로 그리는데,
+ Figma Main Action은 padding 16px 28px(56px)이라 세로 padding만 sx로 맞춘다. */}
+ onApply(event.id)}
+ sx={{ paddingBlock: "16px" }}
+ >
+ {event.actionLabel}
+
+
+ {/* Figma Action Area(110px)는 버튼 아래가 iOS Home Bar 여백까지 합쳐 34px인데,
+ WDS ActionArea는 아래 padding 20px만 준다 — 모자란 14px을 여기서 더한다.
+ 앱 WebView에서는 네이티브 세이프에어리어와 중복이라 데스크톱 프레임에서만 남긴다(BottomNav와 같은 규칙). */}
+
+
+
+ );
+}
+
+export default EventsDetailContent;
diff --git a/src/features/events/components/EventsDetailSkeleton.tsx b/src/features/events/components/EventsDetailSkeleton.tsx
new file mode 100644
index 0000000..7593fb3
--- /dev/null
+++ b/src/features/events/components/EventsDetailSkeleton.tsx
@@ -0,0 +1,55 @@
+import { Skeleton } from "@wanteddev/wds";
+
+import {
+ SKELETON_ROW_KEYS,
+ useSkeletonAnimation,
+} from "@/components/ui/ScreenSkeleton";
+
+// 행사 데이터를 받는 동안 EventsDetailScreen에 보이는 자리 — 정사각 Hero 이미지, 상태 뱃지·제목, 일시/장소/대상
+// 정보 행을 그대로 따른다. 실제 화면처럼 헤더 없이 Hero가 맨 위에서 시작하므로 헤더 슬롯은 비워 둔다.
+function EventsDetailSkeleton() {
+ const animation = useSkeletonAnimation();
+
+ return (
+
+
+
+
+
+
+
+
+ {SKELETON_ROW_KEYS.slice(0, 3).map((key) => (
+
+ ))}
+
+
+
+ );
+}
+
+export default EventsDetailSkeleton;
diff --git a/src/features/events/components/EventsList.tsx b/src/features/events/components/EventsList.tsx
new file mode 100644
index 0000000..16b68bb
--- /dev/null
+++ b/src/features/events/components/EventsList.tsx
@@ -0,0 +1,91 @@
+import { useSuspenseQuery } from "@tanstack/react-query";
+import { Divider } from "@wanteddev/wds";
+import { Fragment } from "react";
+
+import { eventsQueries } from "@/entities/events/eventsQueries";
+import EventsCard from "@/features/events/components/EventsCard";
+import EventsEmptyState from "@/features/events/components/EventsEmptyState";
+
+// Figma: 행사 모집중 empty (nodeId 1165:62713)는 "모집중" 필터 버전만 준다 —
+// 일러스트·레이아웃·"아카이빙 둘러보기" 버튼이 이 조합의 스펙이다.
+// 나머지 필터 문구는 디자인에 없어서 같은 톤으로 맞춰 쓴 것이고, 확정 문구가 나오면 교체한다.
+// 버튼은 Figma가 지정한 모집중에만 노출한다 — 모집종료 필터에서 "지난 행사를 보세요"는 모순이 된다.
+const EMPTY_STATE_BY_FILTER: Record<
+ string,
+ { title: string; description: string; actionLabel?: string }
+> = {
+ all: {
+ description: "새로운 행사가 열리면 알려드릴게요",
+ title: "등록된 행사가 없어요",
+ },
+ closed: {
+ description: "종료된 행사가 아직 없어요",
+ title: "지난 행사가 없어요",
+ },
+ open: {
+ actionLabel: "아카이빙 둘러보기",
+ description: "지난 행사의 활동을 확인해 보세요",
+ title: "모집 중인 행사가 없어요",
+ },
+ upcoming: {
+ description: "새로운 행사가 열리면 알려드릴게요",
+ title: "모집 예정인 행사가 없어요",
+ },
+};
+
+interface EventsListProps {
+ statusFilter: string;
+ onSelect: (eventId: string) => void;
+ onApply: (eventId: string) => void;
+}
+
+// 행사 목록 데이터를 받아 그리는 부분. 데이터를 받는 동안은 EventsListScreen의 Suspense가
+// EventsListSkeleton을 보여준다. 이동 같은 화면 동작은 Screen이 콜백으로 넘긴다.
+function EventsList({ statusFilter, onSelect, onApply }: EventsListProps) {
+ const { data: events } = useSuspenseQuery(eventsQueries.list());
+
+ // 빌릴게 카테고리 필터와 달리 모집 상태는 목데이터에 이미 들어있어서 실제로 걸러낼 수 있다.
+ const visibleEvents =
+ statusFilter === "all"
+ ? events
+ : events.filter((event) => event.status === statusFilter);
+
+ if (visibleEvents.length === 0) {
+ return (
+ // Figma는 Empty State를 목록 영역(헤더·Bottom Nav 사이) 가운데에 둔다.
+ // 카드 목록과 같은 gap-6 래퍼 안에 넣으면 flex-1이 높이를 못 받아 위에 붙어버려서,
+ // 빈 목록일 때는 래퍼를 대체해 h-full로 가운데 정렬한다.
+
+
+
+ );
+ }
+
+ return (
+
+ {visibleEvents.map((event, index) => (
+
+ {index > 0 && (
+
+ )}
+ onApply(event.id)}
+ onSelect={() => onSelect(event.id)}
+ status={event.status}
+ statusLabel={event.statusLabel}
+ title={event.title}
+ />
+
+ ))}
+
+ );
+}
+
+export default EventsList;
diff --git a/src/features/events/components/EventsListSkeleton.tsx b/src/features/events/components/EventsListSkeleton.tsx
new file mode 100644
index 0000000..942e83e
--- /dev/null
+++ b/src/features/events/components/EventsListSkeleton.tsx
@@ -0,0 +1,72 @@
+import { Divider, Skeleton } from "@wanteddev/wds";
+import { Fragment } from "react";
+
+import {
+ SKELETON_ROW_KEYS,
+ useSkeletonAnimation,
+} from "@/components/ui/ScreenSkeleton";
+
+// 행사 목록 데이터를 받는 동안 EventsListScreen의 목록 자리에 보이는 스켈레톤 — 헤더·토글·필터는
+// 화면이 바로 그리므로 행사 카드(EventsCard: 112px 썸네일 + 뱃지·제목·날짜 + 우하단 버튼) 배치만 따른다.
+function EventsListSkeleton() {
+ const animation = useSkeletonAnimation();
+
+ return (
+
+ {SKELETON_ROW_KEYS.map((key, index) => (
+
+ {index > 0 && (
+
+ )}
+
+
+ ))}
+
+ );
+}
+
+export default EventsListSkeleton;
diff --git a/src/features/events/components/EventsStatusBadge.tsx b/src/features/events/components/EventsStatusBadge.tsx
index 14d6c0d..c30cf9e 100644
--- a/src/features/events/components/EventsStatusBadge.tsx
+++ b/src/features/events/components/EventsStatusBadge.tsx
@@ -1,6 +1,6 @@
import { ContentBadge } from "@wanteddev/wds";
-import type { EventStatus } from "@/features/events/constants/events";
+import type { EventStatus } from "@/entities/events/types";
interface EventsStatusBadgeProps {
status: EventStatus;
diff --git a/src/features/events/constants/events.ts b/src/features/events/constants/events.ts
index 85e2130..64ac9c9 100644
--- a/src/features/events/constants/events.ts
+++ b/src/features/events/constants/events.ts
@@ -1,24 +1,3 @@
-export type EventStatus = "open" | "upcoming" | "closed";
-
-export interface EventItem {
- id: string;
- title: string;
- eventDate: string;
- status: EventStatus;
- statusLabel: string;
- actionLabel: string;
- /** 상세 상단 Hero 이미지 개수 — 실 이미지 API 전까지 PageCounter 표기용 */
- imageCount: number;
- /** 상세 메타데이터 "일시" */
- schedule: string;
- /** 상세 메타데이터 "장소" */
- location: string;
- /** 상세 메타데이터 "대상" — Figma가 두 줄로 쪼개 보여줘서 줄 단위로 들고 있는다 */
- audience: string[];
- /** 상세 본문. 줄바꿈을 그대로 살려 렌더링한다 */
- description: string;
-}
-
// 모집 상태 필터. value는 EventStatus + "all"이고, 라벨은 Figma Filter Chips(1243:70861) 그대로.
export const EVENT_STATUS_FILTERS = [
{ label: "전체", value: "all" },
@@ -26,73 +5,3 @@ export const EVENT_STATUS_FILTERS = [
{ label: "모집예정", value: "upcoming" },
{ label: "모집종료", value: "closed" },
] as const;
-
-// Figma 상세(1133:42433 모집중 / 1156:53992 모집예정)의 메타데이터·본문은 목업이라 행사별로 다르지 않다.
-// 목록 카드가 같은 행사명 4개를 상태만 바꿔 보여주는 것과 같은 이유로, 상세 내용도 공통 상수로 두고
-// 상태별로 갈리는 값(뱃지 문구, CTA 문구)만 항목마다 다르게 준다.
-const MOCK_SCHEDULE = "4월 15일 (수) 18:30 ~";
-const MOCK_LOCATION = "미래관 4층 신관 입구 (예대 방면)";
-const MOCK_AUDIENCE = ["소프트웨어융합대학 과학생회비 납부자", "선착순 150명"];
-const MOCK_DESCRIPTION = `안녕하십니까, 제10대 소프트웨어융합대학 학생회 ‘에코’입니다.
-
-기말고사를 준비하고 계신 학우 여러분을 응원하기 위해 간식행사를 진행합니다 🍱✨
-
-시험기간 동안 든든하게 힘내시길 바라며, 많은 관심과 참여 부탁드립니다!
-
-📌 간식행사 일정
-▪️ 일시 : 6월 1일 (월) 11:00 ~
-▪️ 장소 : 미래관 4층 신관 입구 (예대방면)
-
-📌 대상
-▪️ 소프트웨어융합대학 재학생 선착순 180명
-※ 과학생회비 미납부자 참여 가능
-
-📌 간식행사 메뉴
-▪️ 돈까스 도련님 도시락
-▪️ 나랑드사이다 제로
-
-📌 유의 사항
-▪️ 소프트웨어융합대학 학생임을 증명할 수 있는 모바일 학생증 혹은 실물 학생증을 지참해주시기 바랍니다.
-▪️ 1인당 1세트만 수령 가능하며, 선착순 수량 소진 시 수령이 불가능합니다.
-
-많은 학우 여러분의 관심과 참여 부탁드립니다.
-감사합니다 😊`;
-
-// Figma: 행사 Event List (nodeId 1243:70866) 문구를 그대로 옮긴 목데이터 — 실 API 연동 전까지 사용.
-//
-// Figma 목업은 같은 행사명 4개(모집중 1 / 모집예정 1 / 모집종료 2)를 상태만 바꿔 보여주는데,
-// 여기서는 모집중을 비우고 모집종료 중복도 하나로 줄였다(모집예정 1 / 모집종료 1).
-//
-// Empty State(1165:62713)는 Figma가 "모집중" 필터 버전으로만 그려져 있고, 일러스트·문구와
-// "아카이빙 둘러보기" 버튼이 그 조합의 스펙이다. 목데이터에 모집중 항목이 있으면 이 화면을
-// 아예 볼 수 없어서 모집중을 비웠다. 대신 모집중 상세(1133:42433)는 카드로 진입할 수 없다 —
-// 실 API가 붙으면 사라질 제약이고, 지금 확인이 필요하면 아래 항목 하나의 status를 "open"으로
-// 되돌리면 된다. 모집예정은 상세 디자인(1156:53992)이 있어 진입 가능하게 남겼다.
-export const EVENTS: EventItem[] = [
- {
- actionLabel: "모집종료",
- audience: MOCK_AUDIENCE,
- description: MOCK_DESCRIPTION,
- eventDate: "행사일 2026.06.04",
- id: "sw-sports-day-closed",
- imageCount: 7,
- location: MOCK_LOCATION,
- schedule: MOCK_SCHEDULE,
- status: "closed",
- statusLabel: "모집종료",
- title: "소프트웨어융합대학 체육대회",
- },
- {
- actionLabel: "8월 10일 오픈",
- audience: MOCK_AUDIENCE,
- description: MOCK_DESCRIPTION,
- eventDate: "행사일 2026.06.04",
- id: "sw-sports-day-upcoming",
- imageCount: 7,
- location: MOCK_LOCATION,
- schedule: MOCK_SCHEDULE,
- status: "upcoming",
- statusLabel: "모집예정",
- title: "소프트웨어융합대학 체육대회",
- },
-];
diff --git a/src/features/notices/NoticesDetailScreen.tsx b/src/features/notices/NoticesDetailScreen.tsx
index 4d8576a..bdbbcc4 100644
--- a/src/features/notices/NoticesDetailScreen.tsx
+++ b/src/features/notices/NoticesDetailScreen.tsx
@@ -1,121 +1,19 @@
-import {
- ContentBadge,
- type ThemeColorsToken,
- TopNavigationButton,
- Typography,
-} from "@wanteddev/wds";
-import { IconChevronLeft } from "@wanteddev/wds-icon";
+import { Suspense } from "react";
import { useNavigate, useParams } from "react-router-dom";
-import PhotoGallery from "@/components/ui/PhotoGallery";
-import ScreenHeader from "@/components/ui/ScreenHeader";
-import { useScreenHeader } from "@/components/ui/useScreenHeader";
-import {
- NOTICES,
- type NoticeCategory,
-} from "@/features/notices/constants/notices";
+import NoticesDetailContent from "@/features/notices/components/NoticesDetailContent";
+import NoticesDetailSkeleton from "@/features/notices/components/NoticesDetailSkeleton";
-const CATEGORY_BADGE_COLOR: Record
= {
- 일반: "semantic.accent.foreground.blue",
- 제휴: "semantic.accent.foreground.redOrange",
-};
-
-// Figma: 공지 상세 (nodeId 1256:81842), 공지 상세 - 사진 없을 때 (nodeId 1256:81856)
+// 공지 상세 화면 — 이동 같은 화면 동작만 정하고, 공지 데이터를 받는 동안은 상세 배치를 따른
+// 스켈레톤을 보여준다. 헤더도 데이터(사진 유무)에 따라 달라서 화면 전체가 데이터 영역이다.
function NoticesDetailScreen() {
- const { noticeId } = useParams();
const navigate = useNavigate();
- const notice = NOTICES.find((item) => item.id === noticeId);
- const photoCount = notice?.photoCount ?? 0;
-
- const backButton = (
- navigate(-1)}
- variant="icon"
- >
-
-
- );
-
- // 사진이 있으면 행사 상세와 같은 방식으로 뒤로가기를 사진 위 오버레이로 그려서 사진이 화면
- // 최상단부터 시작하게 한다(ScreenLayout 헤더 슬롯을 비우면 0px로 접힌다). 사진이 없으면
- // 덮을 이미지가 없어서 기존처럼 흰 배경 헤더를 쓴다.
- useScreenHeader(
- notice?.hasThumbnail ? null : (
-
- ),
- );
-
- if (!notice) {
- return (
-
-
- 존재하지 않는 공지예요
-
-
- );
- }
+ const { noticeId = "" } = useParams<{ noticeId: string }>();
return (
-
- {notice.hasThumbnail && (
-
{backButton}
- }
- photoCount={photoCount}
- showCounter
- slideClassName="aspect-square"
- />
- )}
-
-
-
-
- {notice.category} 공지
-
-
-
- {notice.title}
-
-
- 등록일 {notice.date}
-
-
-
-
-
- {notice.body}
-
-
-
+