Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion docs/conventions/coding-style.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,11 +41,19 @@

새 옵션이 필요하면 `src/app/ScreenLayoutRoute.tsx`의 `ScreenRouteHandle`에 필드를 추가하고, 그 값을 `ScreenLayout` prop으로 넘긴다. 현재 필드는 `hasBottomNav`(하단 탭 표시)와 `background`(375×812 프레임 배경 — 헤더 뒤까지 포함이라 화면 본문에서 칠할 수 없다. 신청 완료처럼 Figma가 흰 배경으로 그린 화면만 `"normal"`)다.
- `ScreenLayout`은 **라우터를 모르는 prop 기반 컴포넌트**로 유지한다. 라우트 정보(`useMatches`)는 `ScreenLayoutRoute`만 읽는다.
- 화면 스택을 쌓는 이동(목록→상세, 상세→신청 등)은 `navigate(to, { viewTransition: true })`·`<Link viewTransition>`으로 슬라이드 전환을 켠다. 뒤로가기는 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 상태, 이동 같은 동작을 맡고, 데이터를 받는 영역만 `<Suspense fallback={<전용 스켈레톤 />}>`으로 감싼다. `useSuspenseQuery`를 부르고 데이터를 그리는 부분은 `features/<기능>/components/`의 컴포넌트(`EventsList`, `EventsDetailContent` 등)로 분리하고, 이동 같은 동작은 콜백 prop으로 받는다.
- 데이터와 무관한 헤더·탭·필터는 Suspense 밖에서 바로 그리고, 스켈레톤은 데이터 영역의 배치만 따라 그린다. 전용 스켈레톤은 `features/<기능>/components/<화면>Skeleton.tsx`에 두고 WDS `Skeleton`으로 그린다. 헤더가 데이터에 따라 달라지는 화면(공지 상세)은 헤더도 데이터 컴포넌트가 등록하고, 스켈레톤이 `useScreenHeaderSkeleton`으로 헤더 자리를 채운다.

## 에러 / 비동기

- async는 try/catch 또는 서버 상태 라이브러리(도입 시)의 에러 상태로 다룬다. **빈 catch 금지**.
- async는 try/catch 또는 TanStack Query의 에러 상태로 다룬다. **빈 catch 금지**.
- 사용자에게 보이는 메시지와 개발 로깅을 구분한다.

## 주석
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
},
"dependencies": {
"@ncdai/react-wheel-picker": "^1.2.3",
"@tanstack/react-query": "^5.104.0",
"@wanteddev/wds": "^3.12.0",
"@wanteddev/wds-icon": "^3.12.0",
"lottie-react": "^3.1.2",
Expand Down
18 changes: 18 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

40 changes: 39 additions & 1 deletion src/app/ScreenLayoutRoute.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { useMatches } from "react-router-dom";
import { useLayoutEffect, useRef } from "react";
import { useLocation, useMatches, useNavigationType } from "react-router-dom";

import ScreenLayout, {
type ScreenBackground,
Expand Down Expand Up @@ -32,10 +33,47 @@ function resolveScreenRouteOption<K extends keyof ScreenRouteHandle>(
return undefined;
}

// createBrowserRouter는 history.state.idx에 히스토리 스택 위치를 적어 둔다(push마다 1씩 증가).
function getHistoryIndex(): number | null {
const state: unknown = window.history.state;
if (
typeof state === "object" &&
state !== null &&
"idx" in state &&
typeof state.idx === "number"
) {
return state.idx;
}
return null;
}

// ScreenLayout은 라우터를 모르는 prop 기반 레이아웃으로 두고, 이 컴포넌트가 현재 라우트의 handle을
// 읽어 prop으로 넘기기만 한다. 그래서 옵션이 다른 화면이 생겨도 레이아웃 라우트를 따로 선언하지 않고,
// 화면을 오가도 레이아웃이 다시 마운트되지 않는다.
function ScreenLayoutRoute() {
const navigationType = useNavigationType();
const location = useLocation();
const historyIndexRef = useRef<number | null>(null);

// 스택 슬라이드 전환(index.css)의 방향. 뒤로가기면 반대로 빠진다.
// POP은 브라우저 뒤로가기·앞으로가기를 구분하지 않아서, 히스토리 위치가 이전보다 작아졌을 때만 뒤로 본다.
// 위치를 알 수 없는 POP은 앱에서 거의 뒤로가기뿐이라 뒤로 처리한다.
// 전환 애니메이션은 새 화면이 커밋된 뒤 시작되므로, 페인트 전에 도는 layout effect에서 정해 두면
// 이번 전환부터 바로 반영된다.
// biome-ignore lint/correctness/useExhaustiveDependencies: 이동마다(location.key) 방향을 다시 정한다
useLayoutEffect(() => {
const historyIndex = getHistoryIndex();
const previousHistoryIndex = historyIndexRef.current;
historyIndexRef.current = historyIndex;

const isForwardPop =
historyIndex !== null &&
previousHistoryIndex !== null &&
historyIndex > previousHistoryIndex;
document.documentElement.dataset.navigation =
navigationType === "POP" && !isForwardPop ? "back" : "forward";
}, [navigationType, location.key]);

const handles = useMatches()
.map((match) => match.handle)
.filter(isScreenRouteHandle);
Expand Down
15 changes: 12 additions & 3 deletions src/app/router.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,14 @@ const routes = [
children: [
{
children: [
{ element: <HomeScreen />, path: "/" },
{ element: <BililgeListScreen />, path: "/bililge" },
{
element: <HomeScreen />,
path: "/",
},
{
element: <BililgeListScreen />,
path: "/bililge",
},
{
element: <EventsListScreen />,
// 카드 없이 구분선으로만 나뉘는 목록이라 화면 전체가 흰 면이다
Expand Down Expand Up @@ -122,7 +128,10 @@ const routes = [
path: "/chat",
},
// 라우트가 없는 경로 — 레이아웃 안에 둬서 하단 탭이 유지되고, 탭 경로(/event 등)면 그 탭이 활성으로 보인다
{ element: <ComingSoonScreen />, path: "*" },
{
element: <ComingSoonScreen />,
path: "*",
},
],
element: <ScreenLayoutRoute />,
},
Expand Down
3 changes: 2 additions & 1 deletion src/components/ui/ScreenLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ interface ScreenLayoutProps {
// 각자 자기 배경을 그 자리까지 연장해야 해서 컴포넌트마다 h-safe-bottom으로 처리한다.
// 배경도 같은 방식으로 라우트 handle에서 받는다 — 헤더 슬롯까지 이 루트 div가 덮기 때문에,
// 화면이 헤더와 본문에 따로 배경을 깔 필요가 없다.
// 화면 전환(index.css의 스택 슬라이드)은 이 컬럼만 움직인다 — view-transition-name: screen.
function ScreenLayout({
hasBottomNav = true,
background = "alternative",
Expand All @@ -86,7 +87,7 @@ function ScreenLayout({
<ScreenSheetPortalContext.Provider value={sheetPortalEl}>
<ScreenBackgroundPortalContext.Provider value={backgroundPortalEl}>
<div
className={`relative flex h-dvh w-full flex-col overflow-hidden sm:w-[480px] sm:shadow-[0_0_20px_rgba(0,0,0,0.05)] ${BACKGROUND_CLASS_NAMES[background]}`}
className={`relative flex h-dvh w-full flex-col overflow-hidden [view-transition-name:screen] sm:w-[480px] sm:shadow-[0_0_20px_rgba(0,0,0,0.05)] ${BACKGROUND_CLASS_NAMES[background]}`}
>
{/* 화면 전용 배경 포털 대상 — 프레임 안에서 가장 먼저(맨 아래) 그려져서, 투명한
헤더(예: 챗봇 진입 화면)까지 자연스럽게 비쳐 보인다. 콘텐츠가 없는 화면에서는
Expand Down
36 changes: 36 additions & 0 deletions src/components/ui/ScreenSkeleton.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="flex h-14 items-center px-4">
<Skeleton
animation={animation}
height="24px"
radius="6px"
variant="rectangle"
width="24px"
/>
</div>
);
}

// 헤더가 데이터에 따라 달라지는 화면(공지 상세)의 스켈레톤이 헤더 슬롯을 채울 때 쓴다.
// 데이터가 오면 그 화면의 useScreenHeader가 덮어쓴다.
export function useScreenHeaderSkeleton() {
useScreenHeader(<ScreenHeaderSkeleton />);
}

// 목록 스켈레톤의 행 key. 행 수만 필요하고 내용이 없어서 미리 만들어 둔다.
export const SKELETON_ROW_KEYS = ["row-1", "row-2", "row-3", "row-4", "row-5"];
8 changes: 8 additions & 0 deletions src/entities/bililge/bililgeApi.ts
Original file line number Diff line number Diff line change
@@ -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<BililgeItem[]> {
return mockResponse(BILILGE_ITEMS);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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[] = [
Expand Down
12 changes: 12 additions & 0 deletions src/entities/bililge/bililgeQueries.ts
Original file line number Diff line number Diff line change
@@ -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"],
}),
};
6 changes: 6 additions & 0 deletions src/entities/bililge/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
export interface BililgeItem {
id: string;
name: string;
quantity: number;
icon: string;
}
13 changes: 13 additions & 0 deletions src/entities/events/eventsApi.ts
Original file line number Diff line number Diff line change
@@ -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<EventItem[]> {
return mockResponse(EVENTS);
}

// 없는 행사는 null — 상세 화면이 "행사를 찾을 수 없어요" 빈 상태를 그린다.
export function fetchEvent(eventId: string): Promise<EventItem | null> {
return mockResponse(EVENTS.find((event) => event.id === eventId) ?? null);
}
71 changes: 71 additions & 0 deletions src/entities/events/eventsMock.ts
Original file line number Diff line number Diff line change
@@ -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: "소프트웨어융합대학 체육대회",
},
];
17 changes: 17 additions & 0 deletions src/entities/events/eventsQueries.ts
Original file line number Diff line number Diff line change
@@ -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"],
}),
};
20 changes: 20 additions & 0 deletions src/entities/events/types.ts
Original file line number Diff line number Diff line change
@@ -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;
}
13 changes: 13 additions & 0 deletions src/entities/notices/noticesApi.ts
Original file line number Diff line number Diff line change
@@ -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<Notice[]> {
return mockResponse(NOTICES);
}

// 없는 공지는 null — 상세 화면이 "존재하지 않는 공지예요"를 그린다.
export function fetchNotice(noticeId: string): Promise<Notice | null> {
return mockResponse(NOTICES.find((notice) => notice.id === noticeId) ?? null);
}
Loading