diff --git a/docs/conventions/coding-style.md b/docs/conventions/coding-style.md index c48b62b..b406718 100644 --- a/docs/conventions/coding-style.md +++ b/docs/conventions/coding-style.md @@ -39,7 +39,7 @@ } ``` - 새 옵션이 필요하면 `src/app/ScreenLayoutRoute.tsx`의 `ScreenRouteHandle`에 필드를 추가하고, 그 값을 `ScreenLayout` prop으로 넘긴다. 현재 필드는 `hasBottomNav`(하단 탭 표시)와 `background`(375×812 프레임 배경 — 헤더 뒤까지 포함이라 화면 본문에서 칠할 수 없다. 신청 완료처럼 Figma가 흰 배경으로 그린 화면만 `"normal"`)다. + 새 옵션이 필요하면 `src/app/ScreenLayoutRoute.tsx`의 `ScreenRouteHandle`에 필드를 추가하고, 그 값을 `ScreenLayout` prop으로 넘긴다. 현재 필드는 `hasBottomNav`(하단 탭 표시)와 `background`(375×812 프레임 배경 — 헤더 뒤까지 포함이라 화면 본문에서 칠할 수 없다. 신청 완료처럼 Figma가 흰 배경으로 그린 화면만 `"normal"`), `bottomNavValue`(하단 탭에서 선택돼 보일 탭 — 행사 신청내역 `/events/applications`처럼 하단 탭 경로와 주소가 다른 탭 화면만 지정)다. 같은 화면을 탭마다 다른 경로로 쓸 때는 화면이 주소를 해석하지 않고 라우터가 ``처럼 prop으로 넘긴다. - `ScreenLayout`은 **라우터를 모르는 prop 기반 컴포넌트**로 유지한다. 라우트 정보(`useMatches`)는 `ScreenLayoutRoute`만 읽는다. - 헤더는 `useScreenHeader`로 `ScreenLayout`의 헤더 자리에 등록한다. 예외는 홈 하나다 — Figma 홈은 헤더 배경이 없어 배너 그림자가 헤더까지 번지는데, 헤더 자리에 두면 경계에서 그림자가 잘려 선이 생긴다. 그래서 홈만 스크롤 영역 안에 `sticky` 헤더를 직접 그린다(`wds-component-usage.md` 홈 섹션 참고). - 화면 스택을 쌓는 이동(목록→상세, 상세→신청 등)은 `navigate(to, { viewTransition: true })`·``으로 슬라이드 전환을 켠다. 뒤로가기는 react-router가 그 이동을 기억해 반대 방향으로 자동 적용하므로 `navigate(-1)`은 그대로 둔다. 브라우저 앞으로가기도 POP이라, `ScreenLayoutRoute`는 히스토리 위치(`history.state.idx`)가 줄어든 POP만 뒤로 방향으로 본다. Bottom Nav·상단 탭처럼 형제 화면을 오가는 이동과 홈으로 돌아가는 이동은 켜지 않는다(즉시 전환). 애니메이션은 `index.css`, 방향은 `ScreenLayoutRoute`가 정한다. @@ -49,6 +49,9 @@ - 서버 데이터를 받는 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 함수 안쪽만 바꾼다. +- 같은 도메인의 조회 key는 API 파일 안 key 함수 하나(`eventsKeys.application(id)` 등)로 만든다. 조회와 무효화가 문자열을 각자 이어 붙이면, 한쪽만 바뀌었을 때 무효화가 조용히 빗나간다. +- 데이터를 바꾸는 요청(신청 취소 등)은 `mockupMutation(() => ...)`으로 목데이터를 바꾸고, 응답 뒤 바뀐 조회 key를 `invalidateMockupApi([...])`로 지운다(API 함수 안에서 — 화면은 무효화를 모른다). 실 API에서는 TanStack Query의 `useMutation` + `invalidateQueries`가 이 자리를 맡는다. +- 무효화된 데이터를 다시 그리는 상태 업데이트는 `startTransition`으로 감싼다. 캐시가 지워진 조회는 새로 서스펜드되는데, transition 안이면 스켈레톤 대신 지금 화면을 유지한 채 새 데이터로 바뀐다(예: `useEventsApplicationCancel`). - 화면(`<화면>Screen.tsx`)은 헤더 등록, UI 상태, 이동 같은 동작을 맡고, 데이터를 받는 영역만 `}>`으로 감싼다. `use()`로 데이터를 읽고 그리는 부분은 `features/<기능>/components/`의 컴포넌트(`EventsList`, `EventsDetailContent` 등)로 분리하고, 이동 같은 동작은 콜백 prop으로 받는다. - 데이터와 무관한 헤더·탭·필터는 Suspense 밖에서 바로 그리고, 스켈레톤은 데이터 영역의 배치만 따라 그린다. 전용 스켈레톤은 `features/<기능>/components/<화면>Skeleton.tsx`에 두고 WDS `Skeleton`으로 그린다. 헤더가 데이터에 따라 달라지는 화면(공지 상세)은 헤더도 데이터 컴포넌트가 등록하고, 스켈레톤이 `useScreenHeaderSkeleton`으로 헤더 자리를 채운다. diff --git a/docs/conventions/wds-component-usage.md b/docs/conventions/wds-component-usage.md index 2aca321..3c0e6c5 100644 --- a/docs/conventions/wds-component-usage.md +++ b/docs/conventions/wds-component-usage.md @@ -132,6 +132,7 @@ hex를 하드코딩하지 않고 `index.css`의 색상 토큰을 그대로 참 - `width` 기본값이 부모 폭을 채우지 않는다 — 안 주면 카드 폭의 절반 정도로 렌더링돼서 `width="100%"`를 줬다. - 높이 계산용 숨김 textarea(`readonly`, `aria-hidden`)를 하나 더 렌더링한다 — 테스트·자동화에서 `textarea` 셀렉터를 쓸 때 제외해야 한다. - 단답형(50자)도 같은 `TextArea`를 쓴다(Figma 텍스트 입력 예시 `1133:43105`의 디자이너 메모). + - 글자 스타일이 `body1-reading`(16px)으로 고정돼 있고 prop으로 못 바꾼다. Figma 입력 글자·안내 문구는 `Label 1/Reading`(14px)이라(`1658:183424`), `sx`에 `& textarea, & textarea::placeholder { typographyStyle("label1-reading", "regular") }`를 줘서 덮는다(`EventsQuestionField`의 `TEXT_AREA_TYPOGRAPHY`). 위 숨김 textarea도 같은 글자로 높이를 재야 자동 높이가 맞아서 `textarea` 전체를 잡는다. 처음엔 16px 그대로였다가 행사 신청내역(#115) `/figma-check`에서 잡혔다. 신청 폼(`1658:183424`)과 신청내역 상세 둘 다 Figma가 14px다. **WDS를 올릴 때 확인할 곳**: WDS 내부 클래스가 아니라 `textarea` 태그를 잡는 거라 WDS가 textarea를 계속 쓰는 한 유지되지만, 업데이트 뒤 신청 폼 입력 글자가 14px인지·자동 높이가 맞는지 한 번 본다. - **`Action Area/Action Area` → `ActionArea background` + `ActionAreaButton`**: `background`를 켜면 `::before`가 영역 위로 `margin-y`(20px)만큼 더 올라가 그라데이션 마스크로 스크롤 내용을 흐리게 덮는다(Figma `Gradient/Solid`와 일치). `divider`는 기본값이 `true`지만 `extra` 모드에서만 선을 그려서 일반 모드에는 영향 없다. Figma Action Area(110px = 위 20 + 버튼 56 + 아래 34)는 버튼 아래가 iOS **Bottom Safe Area까지 합쳐 34px**인데 WDS `ActionArea`는 아래 padding 20px만 줘서, 모자란 **14px**을 `bg-background-elevated-normal` div로 따로 붙였다(`BottomSheet`에서 14px을 더한 것과 같은 이유). 처음엔 Safe Area 34px을 통째로 더해서 버튼 아래가 54px로 벌어졌었다 — ActionArea 자체 padding과 겹치는지 먼저 확인한다. 이때 `--color-background-elevated-normal`(`--semantic-background-elevated-normal` 별칭) 토큰을 추가했다. - **Action Area 메인 버튼 높이**: `ActionAreaButton`(`main`)은 항상 `Button size="large"`(padding `12px 28px` → **48px**)로 그리는데, Figma `┗ Main Action`은 padding `16px 28px`(**56px**)이다. WDS에 56px 크기가 없어서 `sx={{ paddingBlock: "16px" }}`로 세로 padding만 맞췄다(`ActionAreaButton`은 `props.sx`를 내부 `Button` 스타일 맨 뒤에 붙여서 덮어쓰기가 된다). 빌릴게 대여 바텀시트 Figma(`1422:57205`)의 "대여 신청하기" 버튼도 같은 56px 스펙이지만, `BililgeRentalSheet.tsx`는 아직 `sx` 없이 48px로 렌더링된다(위 "빌릴게 대여 바텀시트" 절의 "정확히 일치" 기록은 높이까지는 대조하지 않은 것으로 보인다). - **기타(직접 입력) 입력칸은 WDS가 아니라 plain ``**: Figma `Other Option`(`1658:183954`)은 체크박스 아래에 밑줄만 있는 입력칸이라, WDS `TextField`(배경·테두리·12px radius가 있는 박스형)와 생김새가 다르다. 밑줄은 `Primary/Normal` 0.7px(에셋 SVG의 stroke로 확인, `get_variable_defs`만으로는 선 색이 안 나온다). 글자는 `Typography as="input"`으로 Figma `Label 1/Reading - Regular`(`variant="label1-reading"`, 14px·줄 높이 22px)를 받는다(`component-convention.md` 타이포그래피 참고). @@ -637,6 +638,22 @@ Figma는 헤더 배경이 없어서 배너 그림자(`Shadow/Spread/Small`, 60px - **사물함 위치 보기(`/my/locker/location`)는 Figma에 없다**: 칸 선택 화면 배치를 보기 전용으로 쓴다. 칸 영역(미니맵·확대 스크롤)은 `LockersMapView`로 두 화면이 공유하고, 범례는 화면이 `legend`로 넘긴다(신청 ``, 위치 보기 `` — "내 사물함" 하나). 내 칸은 배정 정보(`lockerLabel`) 하나에서 정해서 `selectedLockerNumber`로 넘기고, 선택된 칸 모양(Primary/Normal 배경 + 흰 번호)으로 그린다. 보기 전용 여부는 `onSelect`가 없는지로만 판단하고, 이때 칸을 버튼으로 그리지 않는다. 처음 열 때 선택된 칸(`data-selected`)이 화면 밖이면 보일 만큼만 스크롤한다. 신청 화면의 내 칸(구역 목데이터 `isMine`, 초록 테두리, 선택 불가)은 보기 전용에서는 그리지 않는다. - **Figma와 다르게 둔 값**: 요일은 날짜로 계산해서 Figma의 요일 표기와 다를 수 있다. 사물함 번호는 칸 배치 목데이터가 있는 A-1 구역에 맞춰 A-7로 두었다(Figma는 B-25). 사물함 빈 상태 문구의 연도는 기기 시계의 올해다(API 연동 때 서버 값으로 — 코드에 TODO). 날짜는 `lib/date`의 `formatDate`·`formatDateTime`으로 맞추고, 작성내역 상세만 Figma(`3147:147756`)대로 `2026-04-25` 하이픈 표기다. +## 행사 신청내역(`1133:46168` 목록 / `1165:63551` empty / `1133:46181` 상세 / `1133:46131` 신청취소 확인모달 / `1133:46216` 신청취소 상세 + 토스트) 구현 중 확정된 매핑 + +| Figma | 코드 | 확인 내용 | +|---|---|---| +| `Content Badge` (신청완료·신청취소) | `ContentBadge size="small" variant="solid" color="accent"` | 신청완료 green, 신청취소 red. 목록 카드와 상세가 `EVENTS_APPLICATION_STATUS_BADGES`를 같이 쓴다 | +| 카드 `행사 상세` / `신청 상세` | `Button size="small" color="assistive"` | 앞은 `variant="outlined"`, 뒤는 `variant="solid"`(Fill/Normal 배경). 둘 다 padding 7/14·radius 8·Label 2/Medium이 Figma와 같다 | +| 상세 `신청 취소` | `Button size="medium" color="primary" variant="outlined"` + `sx` | WDS Button 색이 primary/assistive뿐이라 글자·테두리(`box-shadow` inset)만 `Status/Negative`로 덮는다 — `sx={NEGATIVE_BUTTON_SX.outlined}`(`components/ui/negativeButtonSx.ts`, `ConfirmModal`의 빨간 확인 버튼은 `.solid`). outlined primary가 SemiBold 글자라 primary를 고른다 | +| 신청취소 확인모달 | `ConfirmModal tone="negative"` | 사물함 신청 확인 모달과 같은 공용 모달이고, `tone="negative"`가 느낌표 아이콘 + 빨간 확인 버튼을 그린다 | +| 취소 완료 토스트 | `ScreenToast variant="positive"` | 체크 아이콘이 WDS 기본값 그대로 | +| 상세 문항 `Control/Checkbox`·`Control/Radio`·`Textinput/Textarea` | 신청 폼의 `EventsQuestionField` 재사용 | `readOnly`로 읽기 전용(선택 표시는 그대로, 바꿀 수 없음). 입력 폼은 `onAnswerChange`가 필수라 빠뜨리면 타입 검사에서 걸린다. 신청취소 상세는 `disabled`로 WDS 비활성 모양을 쓰고, 선택지 글자는 `Label/Disable`(Figma `1133:46240`) | + +- **카드·요약은 Stream 로컬**: 신청내역 카드(`EventsApplicationCard`, 70px 회색 썸네일 + 배지·제목·일시 + 버튼 2개)와 상세 요약(`EventsApplicationSummary`)은 로컬이다. 요약은 신청 폼·완료 화면의 `EventsSummaryCard`(아이콘 + 일시·장소)와 달리 "행사·신청·취소" 라벨 행이라 따로 뒀다. +- **빈 상태는 `EventsEmptyState` 재사용**: 버튼은 `action={{ label, onClick }}` 한 묶음으로 받아서 동작 없는 버튼이 생기지 않는다(신청내역 "행사 둘러보기" → `/events`, 행사 목록 모집중 "아카이빙 둘러보기" → `ARCHIVES_PATH`). 그림·버튼 모양은 행사 목록 빈 상태와 같고, 설명 굵기만 다르다(행사 목록 Medium, 신청내역 Regular) — `descriptionWeight`로 고른다. 위치는 목록 영역 가운데가 아니라 Figma대로 토글 아래 176px이다. +- **탭은 경로로 나눈다**: 행사 `/events`, 신청내역 `/events/applications`, 상세 `/events/applications/:applicationId`. 같은 목록 화면을 라우터가 `tab` prop(`"event"`/`"application"`)으로 골라 넘기고(화면이 주소를 비교하지 않아 끝에 `/`가 붙어도 맞다), 탭을 바꿀 때는 replace라 히스토리가 쌓이지 않는다. 신청내역 탭은 Figma에 상태 필터 행이 없어서 숨긴다. 하단 탭은 신청내역 라우트 `handle`의 `bottomNavValue: "event"`로 "행사"가 선택된다. +- **취소는 목록까지 반영된다**: 취소 API(`cancelMyEventApplication`)가 목데이터를 바꾸고 목록·상세 조회 캐시를 지운다(조회·무효화가 같은 `eventsKeys`로 key를 만든다). 요청·토스트 상태는 `useEventsApplicationCancel` 훅이 맡고, 토스트 문구·색은 `CANCEL_TOASTS` 한 곳에 있다. 다시 그리는 업데이트를 transition으로 묶어 새 데이터를 받는 동안 스켈레톤 대신 지금 화면을 유지하고, 토스트는 취소된 화면과 함께 뜬다. 실패 토스트 문구("신청 취소에 실패했어요. 다시 시도해 주세요.")는 Figma에 없어 임의로 정했다. + ## 알림(`3595:107100` 목록 / `3595:107112` empty / `3628:110837` 설정) 구현 중 확정된 매핑 코드는 `src/features/notifications/` 참고. diff --git a/src/app/ScreenLayoutRoute.tsx b/src/app/ScreenLayoutRoute.tsx index 8266c51..5d26c29 100644 --- a/src/app/ScreenLayoutRoute.tsx +++ b/src/app/ScreenLayoutRoute.tsx @@ -1,6 +1,7 @@ import { useLayoutEffect, useRef } from "react"; import { useLocation, useMatches, useNavigationType } from "react-router-dom"; +import type { BottomNavValue } from "@/components/ui/BottomNav"; import ScreenLayout, { type ScreenBackground, } from "@/components/ui/ScreenLayout"; @@ -12,6 +13,9 @@ export interface ScreenRouteHandle { // 화면 프레임 자체의 배경 — 헤더 뒤까지 포함이라 화면 본문에서 칠할 수 없다. // 기본은 회색(alternative)이고, 완료 화면처럼 Figma가 흰 배경으로 그린 화면만 normal을 지정한다. background?: ScreenBackground; + // 하단 탭에서 선택돼 보일 탭. 하단 탭 경로와 주소가 다른 탭 화면(행사 신청내역 등)만 지정하고, + // 지정하지 않으면 ScreenLayout이 주소로 정한다. + bottomNavValue?: BottomNavValue; } // useMatches()는 handle을 unknown으로 준다. ScreenRouteHandle은 필드가 전부 선택이라 객체면 이 타입으로 본다. @@ -83,6 +87,7 @@ function ScreenLayoutRoute() { background={ resolveScreenRouteOption(handles, "background") ?? "alternative" } + bottomNavValue={resolveScreenRouteOption(handles, "bottomNavValue")} hasBottomNav={resolveScreenRouteOption(handles, "hasBottomNav") ?? true} /> ); diff --git a/src/app/router.tsx b/src/app/router.tsx index f9dd831..2955a99 100644 --- a/src/app/router.tsx +++ b/src/app/router.tsx @@ -13,6 +13,7 @@ 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 EventsApplicationDetailScreen from "@/features/events/EventsApplicationDetailScreen"; import EventsApplicationScreen from "@/features/events/EventsApplicationScreen"; import EventsDetailScreen from "@/features/events/EventsDetailScreen"; import EventsListScreen from "@/features/events/EventsListScreen"; @@ -53,11 +54,26 @@ const routes = [ path: "/bililge", }, { - element: , + element: , // 카드 없이 구분선으로만 나뉘는 목록이라 화면 전체가 흰 면이다 handle: { background: "normal" } satisfies ScreenRouteHandle, path: "/events", }, + { + element: , + // 행사 목록의 신청내역 탭 — 같은 화면을 탭만 바꿔 그리고, 하단 탭은 "행사"가 선택된다 + handle: { + background: "normal", + bottomNavValue: "event", + } satisfies ScreenRouteHandle, + path: "/events/applications", + }, + { + element: , + // 하단 탭 없이 회색 배경(기본값) — 흰 카드가 뜬다 + handle: { hasBottomNav: false } satisfies ScreenRouteHandle, + path: "/events/applications/:applicationId", + }, { element: , // 카드 없이 구분선으로만 나뉘는 목록이라 화면 전체가 흰 면이다 diff --git a/src/components/ui/ConfirmModal.tsx b/src/components/ui/ConfirmModal.tsx index 2f9ae8a..2ecc29a 100644 --- a/src/components/ui/ConfirmModal.tsx +++ b/src/components/ui/ConfirmModal.tsx @@ -4,6 +4,7 @@ import type { KeyboardEvent } from "react"; import { useEffect, useId, useRef } from "react"; import { createPortal } from "react-dom"; +import { NEGATIVE_BUTTON_SX } from "@/components/ui/negativeButtonSx"; import { useScreenSheetPortal } from "@/components/ui/useScreenSheetPortal"; // 모달 안에서 Tab이 돌 수 있는 요소들 — 포커스를 가둘 때 첫/마지막을 찾는 데 쓴다 @@ -192,11 +193,8 @@ function ConfirmModal({ fullWidth onClick={onConfirm} size="medium" - // WDS Button 색상은 primary/assistive뿐이라 위험 버튼은 배경색만 토큰으로 덮어쓴다 sx={{ - backgroundColor: isNegative - ? "var(--color-status-negative)" - : undefined, + ...(isNegative && NEGATIVE_BUTTON_SX.solid), paddingBlock: "12px", }} variant="solid" diff --git a/src/components/ui/ScreenLayout.tsx b/src/components/ui/ScreenLayout.tsx index dfde652..cf1d3b6 100644 --- a/src/components/ui/ScreenLayout.tsx +++ b/src/components/ui/ScreenLayout.tsx @@ -47,6 +47,8 @@ const BACKGROUND_CLASS_NAMES: Record = { interface ScreenLayoutProps { hasBottomNav?: boolean; background?: ScreenBackground; + /** 선택돼 보일 하단 탭. 없으면 주소로 정한다(라우트 handle의 bottomNavValue) */ + bottomNavValue?: BottomNavValue; } // 모든 화면이 공유하는 라우트 레이아웃 — router.tsx에서 부모 route로 두고 화면들을 @@ -78,6 +80,7 @@ interface ScreenLayoutProps { function ScreenLayout({ hasBottomNav = true, background = "alternative", + bottomNavValue: bottomNavValueProp, }: ScreenLayoutProps) { const [header, setHeader] = useState(null); const [sheetPortalEl, setSheetPortalEl] = useState( @@ -87,7 +90,8 @@ function ScreenLayout({ useState(null); const location = useLocation(); const navigate = useNavigate(); - const bottomNavValue = getBottomNavValueFromPath(location.pathname); + const bottomNavValue = + bottomNavValueProp ?? getBottomNavValueFromPath(location.pathname); useNativeSafeAreaColors(background, hasBottomNav); diff --git a/src/components/ui/negativeButtonSx.ts b/src/components/ui/negativeButtonSx.ts new file mode 100644 index 0000000..4fd1713 --- /dev/null +++ b/src/components/ui/negativeButtonSx.ts @@ -0,0 +1,12 @@ +// 위험한 행동(신청 취소·로그아웃·탈퇴)의 빨간 버튼. WDS Button 색은 primary/assistive뿐이라 +// color="primary" 버튼에 Status/Negative 색만 덮는다 — variant마다 덮을 속성이 달라서 둘로 나눈다. +// 색이나 테두리가 바뀌면 여기만 고친다. +export const NEGATIVE_BUTTON_SX = { + // 테두리 버튼 — 글자와 테두리(WDS outlined는 inset box-shadow로 테두리를 그린다) + outlined: { + boxShadow: "inset 0 0 0 1px var(--color-status-negative)", + color: "var(--color-status-negative)", + }, + // 채운 버튼 — 배경만(글자는 primary solid의 흰색 그대로) + solid: { backgroundColor: "var(--color-status-negative)" }, +} as const; diff --git a/src/entities/events/eventsApi.ts b/src/entities/events/eventsApi.ts index 434f203..6eb68a6 100644 --- a/src/entities/events/eventsApi.ts +++ b/src/entities/events/eventsApi.ts @@ -1,16 +1,79 @@ -import { EVENTS } from "@/entities/events/eventsMock"; -import type { EventItem } from "@/entities/events/types"; -import { mockupApi } from "@/lib/mockupApi"; +import { + EVENTS, + MY_EVENT_APPLICATIONS_MOCK, + toMockDateTime, +} from "@/entities/events/eventsMock"; +import type { EventItem, MyEventApplication } from "@/entities/events/types"; +import { + invalidateMockupApi, + mockupApi, + mockupMutation, +} from "@/lib/mockupApi"; + +// 조회 캐시 key. 조회와 무효화가 같은 함수로 key를 만들어서, 한쪽만 바뀌어 무효화가 빗나가지 않게 한다. +const eventsKeys = { + application: (applicationId: string) => + `events/applications/${applicationId}`, + applications: () => "events/applications", + event: (eventId: string) => `events/${eventId}`, + events: () => "events", +}; // 실 API가 붙으면 함수 안쪽만 요청 코드로 바꾼다 — 화면은 이 함수로만 데이터를 받는다. export function fetchEvents(): Promise { - return mockupApi("events", () => EVENTS); + return mockupApi(eventsKeys.events(), () => EVENTS); } // 없는 행사는 null — 상세 화면이 "행사를 찾을 수 없어요" 빈 상태를 그린다. export function fetchEvent(eventId: string): Promise { return mockupApi( - `events/${eventId}`, + eventsKeys.event(eventId), () => EVENTS.find((event) => event.id === eventId) ?? null, ); } + +export function fetchMyEventApplications(): Promise { + return mockupApi(eventsKeys.applications(), () => [ + ...MY_EVENT_APPLICATIONS_MOCK, + ]); +} + +// 없는 신청내역은 null +export function fetchMyEventApplication( + applicationId: string, +): Promise { + return mockupApi( + eventsKeys.application(applicationId), + () => + MY_EVENT_APPLICATIONS_MOCK.find( + (application) => application.id === applicationId, + ) ?? null, + ); +} + +// 신청을 취소하고 바뀐 신청내역을 돌려준다. 목록·상세 조회 캐시를 지워 다음 조회가 취소 상태를 받게 한다. +export async function cancelMyEventApplication( + applicationId: string, +): Promise { + const cancelled = await mockupMutation(() => { + const index = MY_EVENT_APPLICATIONS_MOCK.findIndex( + (application) => application.id === applicationId, + ); + if (index === -1) { + throw new Error(`신청내역을 찾을 수 없어요: ${applicationId}`); + } + const next: MyEventApplication = { + ...MY_EVENT_APPLICATIONS_MOCK[index], + // 실 API에서는 서버가 취소 시각을 정해 돌려준다 + cancelledAt: toMockDateTime(new Date()), + status: "cancelled", + }; + MY_EVENT_APPLICATIONS_MOCK[index] = next; + return next; + }); + invalidateMockupApi([ + eventsKeys.applications(), + eventsKeys.application(applicationId), + ]); + return cancelled; +} diff --git a/src/entities/events/eventsMock.ts b/src/entities/events/eventsMock.ts index 653f22c..ff2391d 100644 --- a/src/entities/events/eventsMock.ts +++ b/src/entities/events/eventsMock.ts @@ -1,4 +1,8 @@ -import type { EventItem } from "@/entities/events/types"; +import type { + EventItem, + EventsQuestion, + MyEventApplication, +} from "@/entities/events/types"; // Figma 상세(1133:42433 모집중 / 1156:53992 모집예정)의 메타데이터·본문은 목업이라 행사별로 다르지 않다. // 목록 카드가 같은 행사명 4개를 상태만 바꿔 보여주는 것과 같은 이유로, 상세 내용도 공통 상수로 두고 @@ -69,3 +73,96 @@ export const EVENTS: EventItem[] = [ title: "소프트웨어융합대학 체육대회", }, ]; + +// Figma: 행사 신청내역 (nodeId 1133:46168 목록, 1133:46181 상세, 1133:46216 신청취소 상세) +// 행사 상세로 이동할 수 있게 eventId는 위 EVENTS에 있는 행사로 맞춘다. + +// 신청 폼(EVENTS_APPLICATION)과 같은 문항 — 신청 당시 문항을 그대로 들고 있는 모양을 흉내 낸다. +const QUESTIONS: EventsQuestion[] = [ + { + hasOtherOption: true, + id: "interest", + isRequired: true, + options: ["개발", "디자인", "기획"], + title: "관심 분야를 선택해 주세요.", + type: "multipleChoice", + }, + { + id: "afterParty", + isRequired: true, + options: ["참여해요", "참여하지 않아요"], + title: "뒤풀이에 참여하시나요?", + type: "singleChoice", + }, + { + id: "question", + isRequired: false, + title: "궁금한 점이 있다면 자유롭게 남겨 주세요.", + type: "longAnswer", + }, +]; + +const APPLICATIONS: MyEventApplication[] = [ + { + answers: { + afterParty: "참여해요", + interest: { otherText: "", selected: ["개발", "디자인"] }, + question: + "궁금한 점이 하나 있습니다. 행사 당일 학생증을 꼭 챙겨야 하나요?", + }, + appliedAt: "2026-06-04T13:00", + cancelledAt: null, + eventDateTime: "2026-06-04T18:30", + eventId: "sw-sports-day-closed", + eventTitle: "소프트웨어융합대학 체육대회", + id: "application-1", + location: "미래관 4층 신관 입구", + questions: QUESTIONS, + status: "applied", + }, + { + answers: { + afterParty: "참여하지 않아요", + interest: { otherText: "", selected: ["기획"] }, + }, + appliedAt: "2026-06-04T13:00", + cancelledAt: null, + eventDateTime: "2026-06-04T18:30", + eventId: "sw-sports-day-upcoming", + eventTitle: "소프트웨어융합대학 체육대회", + id: "application-2", + location: "미래관 4층 신관 입구", + questions: QUESTIONS, + status: "applied", + }, + { + answers: { + afterParty: "참여해요", + interest: { otherText: "", selected: ["개발"] }, + }, + appliedAt: "2026-06-03T11:49", + cancelledAt: "2026-06-04T13:00", + eventDateTime: "2026-06-04T18:30", + eventId: "sw-sports-day-closed", + eventTitle: "소프트웨어융합대학 체육대회", + id: "application-3", + location: "미래관 4층 신관 입구", + questions: QUESTIONS, + status: "cancelled", + }, +]; + +// 상태를 바꿔 신청내역 화면을 확인한다 +type MyEventApplicationsMockState = "list" | "empty"; +const MY_EVENT_APPLICATIONS_MOCK_STATE: MyEventApplicationsMockState = "list"; + +// 신청 취소가 이 배열의 항목을 바꾼다(eventsApi의 cancelMyEventApplication) — 목록·상세에 같이 반영된다. +export const MY_EVENT_APPLICATIONS_MOCK: MyEventApplication[] = + MY_EVENT_APPLICATIONS_MOCK_STATE === "list" ? APPLICATIONS : []; + +// 목데이터 일시 형식("2026-06-04T13:00", 분 단위·현지 시각)으로 바꾼다 — 신청 취소 시각처럼 목에서 새로 만드는 +// 값에 쓴다. 화면 표기용이 아니다(표기는 lib/date). 실 API가 붙으면 서버가 시각을 정해 주므로 같이 지운다. +export function toMockDateTime(date: Date) { + const pad = (value: number) => String(value).padStart(2, "0"); + return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`; +} diff --git a/src/entities/events/types.ts b/src/entities/events/types.ts index 140d997..de0db08 100644 --- a/src/entities/events/types.ts +++ b/src/entities/events/types.ts @@ -18,3 +18,56 @@ export interface EventItem { /** 상세 본문. 줄바꿈을 그대로 살려 렌더링한다 */ description: string; } + +// 행사 신청 문항·답변 — 신청 폼과 신청내역 상세가 같이 쓴다. +interface EventsQuestionBase { + id: string; + title: string; + isRequired: boolean; +} + +export interface EventsChoiceQuestion extends EventsQuestionBase { + type: "multipleChoice" | "singleChoice"; + options: string[]; + // 기타(직접 입력) 선택지를 마지막에 붙일지 — 디자인상 복수 선택에만 있다 + hasOtherOption?: boolean; +} + +export interface EventsTextQuestion extends EventsQuestionBase { + type: "shortAnswer" | "longAnswer"; +} + +// type 값 이름은 임시 — 백엔드 스펙이 나오면 거기에 맞춘다 +export type EventsQuestion = EventsChoiceQuestion | EventsTextQuestion; + +// 복수 선택은 고른 선택지 목록과 기타 입력 내용을 함께 들고 있는다. +// 단일 선택·텍스트형은 문자열 하나. +export interface EventsChoiceAnswer { + selected: string[]; + otherText: string; +} + +export type EventsAnswer = string | EventsChoiceAnswer; + +export type MyEventApplicationStatus = "applied" | "cancelled"; + +interface MyEventApplicationBase { + id: string; + eventId: string; + eventTitle: string; + // "2026-05-12T12:00" + eventDateTime: string; + location: string; + appliedAt: string; + questions: EventsQuestion[]; + // 문항 id → 답변. 답하지 않은 선택 문항은 빠진다 + answers: Record; +} + +// 내 행사 신청내역. 행사 쪽 문항이 나중에 바뀌어도 내가 답한 그대로 보여 주려고 신청 당시 문항을 같이 들고 있다. +// 취소 일시는 신청취소일 때만 있다 — 상태로 갈라 두어 "취소인데 취소 일시가 없는" 경우를 타입에서 막는다. +export type MyEventApplication = MyEventApplicationBase & + ( + | { status: "applied"; cancelledAt: null } + | { status: "cancelled"; cancelledAt: string } + ); diff --git a/src/features/archives/constants/archives.ts b/src/features/archives/constants/archives.ts new file mode 100644 index 0000000..b05a7b4 --- /dev/null +++ b/src/features/archives/constants/archives.ts @@ -0,0 +1,2 @@ +// 아카이빙 목록 화면 경로 — 다른 기능(행사 목록 빈 상태 등)에서 아카이빙으로 보낼 때 쓴다 +export const ARCHIVES_PATH = "/archives"; diff --git a/src/features/events/EventsApplicationCompleteScreen.tsx b/src/features/events/EventsApplicationCompleteScreen.tsx index f886908..d45f615 100644 --- a/src/features/events/EventsApplicationCompleteScreen.tsx +++ b/src/features/events/EventsApplicationCompleteScreen.tsx @@ -3,11 +3,9 @@ import { useNavigate } from "react-router-dom"; import CompleteCheck from "@/components/ui/CompleteCheck"; import ResultScreen from "@/components/ui/ResultScreen"; import EventsSummaryCard from "@/features/events/components/EventsSummaryCard"; +import { EVENTS_TAB_PATHS } from "@/features/events/constants/events"; import { EVENTS_APPLICATION } from "@/features/events/constants/eventsApplication"; -// 신청내역 화면은 아직 없다 — 라우트가 없어서 준비 중 화면(ComingSoonScreen)이 뜬다. -const APPLICATION_HISTORY_PATH = "/my/applications"; - // Figma: 행사 신청 완료 페이지 (nodeId 1712:192283) // 행사 정보는 신청 폼과 마찬가지로 API 연동 전까지 목업 하나를 보여준다. function EventsApplicationCompleteScreen() { @@ -24,7 +22,7 @@ function EventsApplicationCompleteScreen() { primaryAction={{ label: "홈으로 가기", onClick: () => navigate("/") }} secondaryAction={{ label: "신청내역 보기", - onClick: () => navigate(APPLICATION_HISTORY_PATH), + onClick: () => navigate(EVENTS_TAB_PATHS.application), }} title="신청이 완료됐어요" > diff --git a/src/features/events/EventsApplicationDetailScreen.tsx b/src/features/events/EventsApplicationDetailScreen.tsx new file mode 100644 index 0000000..3f65c09 --- /dev/null +++ b/src/features/events/EventsApplicationDetailScreen.tsx @@ -0,0 +1,83 @@ +import { TopNavigationButton } from "@wanteddev/wds"; +import { IconChevronLeft } from "@wanteddev/wds-icon"; +import { Suspense, useState } from "react"; +import { useParams } from "react-router-dom"; + +import ConfirmModal from "@/components/ui/ConfirmModal"; +import ScreenHeader from "@/components/ui/ScreenHeader"; +import ScreenToast from "@/components/ui/ScreenToast"; +import { useScreenHeader } from "@/components/ui/useScreenHeader"; +import EventsApplicationDetailContent from "@/features/events/components/EventsApplicationDetailContent"; +import EventsApplicationDetailSkeleton from "@/features/events/components/EventsApplicationDetailSkeleton"; +import { EVENTS_TAB_PATHS } from "@/features/events/constants/events"; +import { useEventsApplicationCancel } from "@/features/events/hooks/useEventsApplicationCancel"; +import { useBackNavigation } from "@/hooks/useBackNavigation"; + +// Figma: 신청내역 상세 (nodeId 1133:46181), 신청취소 확인모달 (1133:46131), 신청취소 상세 + 토스트 (1133:46216) +function EventsApplicationDetailScreen() { + const { applicationId = "" } = useParams(); + const [isConfirmOpen, setIsConfirmOpen] = useState(false); + const { cancel, closeToast, isCancelling, isToastOpen, toast, toastKey } = + useEventsApplicationCancel(applicationId); + + // 바로 들어와 뒤로 갈 히스토리가 없으면 신청내역 목록으로 보낸다 + const goBack = useBackNavigation(EVENTS_TAB_PATHS.application); + + useScreenHeader( + + + + } + title="신청 상세" + variant="normal" + />, + ); + + return ( +
+ }> + setIsConfirmOpen(true)} + /> + + + setIsConfirmOpen(false)} + onConfirm={() => { + setIsConfirmOpen(false); + cancel(); + }} + open={isConfirmOpen} + title="신청을 취소할까요?" + tone="negative" + /> + + { + if (!open) { + closeToast(); + } + }} + open={isToastOpen} + variant={toast.variant} + /> +
+ ); +} + +export default EventsApplicationDetailScreen; diff --git a/src/features/events/EventsApplicationScreen.tsx b/src/features/events/EventsApplicationScreen.tsx index 93c7815..5131d9d 100644 --- a/src/features/events/EventsApplicationScreen.tsx +++ b/src/features/events/EventsApplicationScreen.tsx @@ -12,12 +12,12 @@ import ScreenHeader from "@/components/ui/ScreenHeader"; import ScreenToast from "@/components/ui/ScreenToast"; import SubmittingOverlay from "@/components/ui/SubmittingOverlay"; import { useScreenHeader } from "@/components/ui/useScreenHeader"; +import type { EventsAnswer } from "@/entities/events/types"; import EventsQuestionField from "@/features/events/components/EventsQuestionField"; import EventsSummaryCard from "@/features/events/components/EventsSummaryCard"; import { EVENTS_APPLICATION, EVENTS_OTHER_OPTION_LABEL, - type EventsAnswer, } from "@/features/events/constants/eventsApplication"; import { submitEventsApplication } from "@/features/events/constants/eventsApplicationSubmit"; diff --git a/src/features/events/EventsListScreen.tsx b/src/features/events/EventsListScreen.tsx index 3894b3e..8b5b50f 100644 --- a/src/features/events/EventsListScreen.tsx +++ b/src/features/events/EventsListScreen.tsx @@ -2,7 +2,6 @@ import { SegmentedControl, SegmentedControlItem, TopNavigationButton, - Typography, } from "@wanteddev/wds"; import { IconBell, IconSearch } from "@wanteddev/wds-icon"; import { Suspense, useState } from "react"; @@ -11,16 +10,27 @@ 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 { ARCHIVES_PATH } from "@/features/archives/constants/archives"; +import EventsApplicationList from "@/features/events/components/EventsApplicationList"; +import EventsApplicationListSkeleton from "@/features/events/components/EventsApplicationListSkeleton"; import EventsList from "@/features/events/components/EventsList"; import EventsListSkeleton from "@/features/events/components/EventsListSkeleton"; -import { EVENT_STATUS_FILTERS } from "@/features/events/constants/events"; +import { + EVENT_STATUS_FILTERS, + EVENTS_TAB_PATHS, + type EventsTab, +} from "@/features/events/constants/events"; import { NOTIFICATIONS_PATH } from "@/features/notifications/constants/notifications"; import { getSearchPath } from "@/features/search/constants/search"; +interface EventsListScreenProps { + /** 행사/신청내역 탭 — 탭마다 경로가 달라서 라우터가 정해 넘긴다 */ + tab: EventsTab; +} + // Figma: 행사 (nodeId 1243:70854) -function EventsListScreen() { +function EventsListScreen({ tab }: EventsListScreenProps) { const navigate = useNavigate(); - const [tab, setTab] = useState("event"); const [statusFilter, setStatusFilter] = useState("all"); useScreenHeader( @@ -56,7 +66,14 @@ function EventsListScreen() { Figma Tool 프레임(56~88)은 높이 32에 위아래 여백이 없다 — 세로 패딩을 주면 토글과 그 아래 필터 행이 함께 밀린다. */}
- + + navigate(EVENTS_TAB_PATHS[value as EventsTab], { replace: true }) + } + size="small" + value={tab} + > 행사 신청내역 @@ -64,23 +81,30 @@ function EventsListScreen() {
- {/* 필터는 고정하고 목록만 스크롤된다 */} -
- -
+ {/* 필터는 고정하고 목록만 스크롤된다. 신청내역 탭은 Figma(1133:46168)에 필터 행이 없다 */} + {tab === "event" && ( +
+ +
+ )}
- {/* 헤더·토글·필터는 데이터와 무관해서 바로 그리고, 데이터를 받는 목록 자리만 스켈레톤으로 채운다 */} + {/* 헤더·토글·필터는 데이터와 무관해서 바로 그리고, 데이터를 받는 목록 자리만 스켈레톤으로 채운다. + 탭 전환은 라우터 이동(transition)이라, 두 Suspense가 같은 자리의 같은 경계로 보이면 새 탭 데이터를 + 받는 동안 이전 탭 화면을 붙잡아 탭이 멈춘 것처럼 보인다 — key로 탭마다 다른 경계로 둔다. */} {tab === "event" ? ( - }> + } key="event"> navigate(`/events/${eventId}/apply`, { viewTransition: true }) } + onBrowseArchives={() => + navigate(ARCHIVES_PATH, { viewTransition: true }) + } onSelect={(eventId) => navigate(`/events/${eventId}`, { viewTransition: true }) } @@ -88,15 +112,25 @@ function EventsListScreen() { /> ) : ( -
- - 신청내역은 아직 준비 중이에요 - -
+ } + key="application" + > + + navigate(EVENTS_TAB_PATHS.event, { replace: true }) + } + onSelectApplication={(applicationId) => + navigate(`${EVENTS_TAB_PATHS.application}/${applicationId}`, { + viewTransition: true, + }) + } + onSelectEvent={(eventId) => + navigate(`/events/${eventId}`, { viewTransition: true }) + } + /> + )}
diff --git a/src/features/events/components/EventsApplicationCard.tsx b/src/features/events/components/EventsApplicationCard.tsx new file mode 100644 index 0000000..0dbd708 --- /dev/null +++ b/src/features/events/components/EventsApplicationCard.tsx @@ -0,0 +1,93 @@ +import { Button, ContentBadge, Typography } from "@wanteddev/wds"; + +import type { MyEventApplication } from "@/entities/events/types"; +import { EVENTS_APPLICATION_STATUS_BADGES } from "@/features/events/constants/events"; +import { formatDateTime } from "@/lib/date"; + +interface EventsApplicationCardProps { + application: MyEventApplication; + onSelectEvent: () => void; + onSelectApplication: () => void; +} + +// Figma: ApplicationHistory Card (nodeId 1133:46176, 신청취소 1133:46180) — Stream 로컬 카드. +// 날짜 줄은 신청완료면 신청 일시, 신청취소면 취소 일시다. +// 두 버튼은 WDS Button size="small"이 padding 7/14·radius 8·Label 2/Medium까지 Figma와 같다 — +// `행사 상세`는 outlined, `신청 상세`는 solid(Fill/Normal 배경)이고 색은 둘 다 assistive다. +function EventsApplicationCard({ + application, + onSelectEvent, + onSelectApplication, +}: EventsApplicationCardProps) { + const badge = EVENTS_APPLICATION_STATUS_BADGES[application.status]; + const dateLabel = + application.status === "cancelled" + ? `${formatDateTime(application.cancelledAt)} 취소` + : `${formatDateTime(application.appliedAt)} 신청`; + + return ( +
+
+ {/* 행사 이미지 API 전까지는 행사 목록 카드와 같은 단색 자리 */} +
+
+ + {badge.label} + +
+ + {application.eventTitle} + + + {dateLabel} + +
+
+
+
+
+ +
+
+ +
+
+
+ ); +} + +export default EventsApplicationCard; diff --git a/src/features/events/components/EventsApplicationDetailContent.tsx b/src/features/events/components/EventsApplicationDetailContent.tsx new file mode 100644 index 0000000..8c9af93 --- /dev/null +++ b/src/features/events/components/EventsApplicationDetailContent.tsx @@ -0,0 +1,68 @@ +import { Button } from "@wanteddev/wds"; +import { use } from "react"; +import { Navigate } from "react-router-dom"; + +import { NEGATIVE_BUTTON_SX } from "@/components/ui/negativeButtonSx"; +import { fetchMyEventApplication } from "@/entities/events/eventsApi"; +import EventsApplicationSummary from "@/features/events/components/EventsApplicationSummary"; +import EventsQuestionField from "@/features/events/components/EventsQuestionField"; + +interface EventsApplicationDetailContentProps { + applicationId: string; + /** 없는 신청내역일 때 돌려보낼 곳 */ + fallbackPath: string; + /** 신청 취소 요청 중에는 버튼을 막아 두 번 보내지 않게 한다 */ + isCancelling: boolean; + onCancel: () => void; +} + +// Figma: 신청내역 상세 (nodeId 1133:46181), 신청취소 상세 (1133:46216) +// 신청 당시 문항에 내가 낸 답변을 채워 읽기 전용으로 보여 준다. 취소된 신청은 입력들을 흐리게 한다. +function EventsApplicationDetailContent({ + applicationId, + fallbackPath, + isCancelling, + onCancel, +}: EventsApplicationDetailContentProps) { + const application = use(fetchMyEventApplication(applicationId)); + + // 없는 신청내역이면 볼 내용이 없어서 화면이 정한 곳(신청내역 목록)으로 돌려보낸다 + if (!application) { + return ; + } + + const isCancelled = application.status === "cancelled"; + + return ( +
+
+ + {application.questions.map((question) => ( + + ))} +
+ {/* Figma 신청취소 상세(1133:46216)에는 버튼이 없다 — 신청완료일 때만 보인다. + outlined primary(SemiBold 글자)에 빨간 글자·테두리를 덮는다(NEGATIVE_BUTTON_SX). */} + {!isCancelled && ( + + )} +
+ ); +} + +export default EventsApplicationDetailContent; diff --git a/src/features/events/components/EventsApplicationDetailSkeleton.tsx b/src/features/events/components/EventsApplicationDetailSkeleton.tsx new file mode 100644 index 0000000..42aae9c --- /dev/null +++ b/src/features/events/components/EventsApplicationDetailSkeleton.tsx @@ -0,0 +1,69 @@ +import { Skeleton } from "@wanteddev/wds"; + +import { useSkeletonAnimation } from "@/components/ui/ScreenSkeleton"; + +const QUESTION_SKELETON_KEYS = ["question-1", "question-2", "question-3"]; + +// 신청내역 상세를 받는 동안 보이는 스켈레톤 — 행사 요약 카드와 문항 카드 배치만 따른다. +function EventsApplicationDetailSkeleton() { + const animation = useSkeletonAnimation(); + + return ( +
+
+ + + + +
+ {QUESTION_SKELETON_KEYS.map((key) => ( +
+ + + +
+ ))} +
+ ); +} + +export default EventsApplicationDetailSkeleton; diff --git a/src/features/events/components/EventsApplicationList.tsx b/src/features/events/components/EventsApplicationList.tsx new file mode 100644 index 0000000..61a5690 --- /dev/null +++ b/src/features/events/components/EventsApplicationList.tsx @@ -0,0 +1,57 @@ +import { Divider } from "@wanteddev/wds"; +import { Fragment, use } from "react"; + +import { fetchMyEventApplications } from "@/entities/events/eventsApi"; +import EventsApplicationCard from "@/features/events/components/EventsApplicationCard"; +import EventsEmptyState from "@/features/events/components/EventsEmptyState"; + +interface EventsApplicationListProps { + onSelectEvent: (eventId: string) => void; + onSelectApplication: (applicationId: string) => void; + onBrowseEvents: () => void; +} + +// Figma: 행사/신청내역 (nodeId 1133:46168), 빈 상태 (1165:63551) +// 카드 사이는 행사 목록과 같은 간격(24)과 구분선이다. +function EventsApplicationList({ + onSelectEvent, + onSelectApplication, + onBrowseEvents, +}: EventsApplicationListProps) { + const applications = use(fetchMyEventApplications()); + + if (applications.length === 0) { + return ( + // Figma는 가운데가 아니라 토글 아래 176px에 둔다(1165:63551, Empty State y=318) +
+ +
+ ); + } + + return ( +
+ {applications.map((application, index) => ( + + {index > 0 && ( +
+ +
+ )} + onSelectApplication(application.id)} + onSelectEvent={() => onSelectEvent(application.eventId)} + /> +
+ ))} +
+ ); +} + +export default EventsApplicationList; diff --git a/src/features/events/components/EventsApplicationListSkeleton.tsx b/src/features/events/components/EventsApplicationListSkeleton.tsx new file mode 100644 index 0000000..e177a80 --- /dev/null +++ b/src/features/events/components/EventsApplicationListSkeleton.tsx @@ -0,0 +1,82 @@ +import { Divider, Skeleton } from "@wanteddev/wds"; +import { Fragment } from "react"; + +import { + SKELETON_ROW_KEYS, + useSkeletonAnimation, +} from "@/components/ui/ScreenSkeleton"; + +// 신청내역을 받는 동안 보이는 스켈레톤 — 신청내역 카드(70px 썸네일 + 배지·제목·날짜 + 버튼 두 개) 배치만 따른다. +function EventsApplicationListSkeleton() { + const animation = useSkeletonAnimation(); + + return ( +
+ {SKELETON_ROW_KEYS.map((key, index) => ( + + {index > 0 && ( +
+ +
+ )} +
+
+ +
+ + + +
+
+
+
+ +
+
+ +
+
+
+
+ ))} +
+ ); +} + +export default EventsApplicationListSkeleton; diff --git a/src/features/events/components/EventsApplicationSummary.tsx b/src/features/events/components/EventsApplicationSummary.tsx new file mode 100644 index 0000000..397ae02 --- /dev/null +++ b/src/features/events/components/EventsApplicationSummary.tsx @@ -0,0 +1,75 @@ +import { ContentBadge, Typography } from "@wanteddev/wds"; + +import type { MyEventApplication } from "@/entities/events/types"; +import { EVENTS_APPLICATION_STATUS_BADGES } from "@/features/events/constants/events"; +import { formatDateTime } from "@/lib/date"; + +// Figma: 신청내역 상세 Event Summary (nodeId 1133:46188), 신청취소 상세 (1133:46223) — Stream 로컬 카드. +// 신청 폼·완료 화면의 EventsSummaryCard(아이콘 + 일시·장소)와 달리 "행사·신청·취소" 라벨 행이라 따로 둔다. +function EventsApplicationSummary({ + application, +}: { + application: MyEventApplication; +}) { + const badge = EVENTS_APPLICATION_STATUS_BADGES[application.status]; + const rows = [ + { + label: "행사", + value: `${formatDateTime(application.eventDateTime)} · ${application.location}`, + }, + { label: "신청", value: formatDateTime(application.appliedAt) }, + ...(application.status === "cancelled" + ? [ + { + label: "취소", + value: formatDateTime(application.cancelledAt), + }, + ] + : []), + ]; + + return ( +
+ + {badge.label} + + + {application.eventTitle} + +
+ {rows.map((row) => ( +
+ + {row.label} + + + {row.value} + +
+ ))} +
+
+ ); +} + +export default EventsApplicationSummary; diff --git a/src/features/events/components/EventsEmptyState.tsx b/src/features/events/components/EventsEmptyState.tsx index 7bc0c42..604b6a1 100644 --- a/src/features/events/components/EventsEmptyState.tsx +++ b/src/features/events/components/EventsEmptyState.tsx @@ -5,8 +5,10 @@ import emptyEventsIllustration from "@/assets/icons/common/empty-events.svg"; interface EventsEmptyStateProps { title: string; description: string; - /** 있을 때만 버튼을 노출한다 — Figma는 모집중 필터에만 "아카이빙 둘러보기"를 둔다 */ - actionLabel?: string; + /** 있을 때만 버튼을 노출한다 — 문구와 동작을 한 묶음으로 받아, 동작 없는 버튼이 그려지지 않게 한다 */ + action?: { label: string; onClick: () => void }; + /** 설명 굵기 — 행사 목록 empty(1165:62725)는 Medium, 신청내역 empty(1410:52742)는 Regular다 */ + descriptionWeight?: "medium" | "regular"; } // Figma: 행사 모집중 empty > Empty State (nodeId 1165:62725) @@ -17,17 +19,14 @@ interface EventsEmptyStateProps { function EventsEmptyState({ title, description, - actionLabel, + action, + descriptionWeight = "medium", }: EventsEmptyStateProps) { return ( -
- {/* Figma는 81px 박스 안에 71×70.109 아트를 left 7 / top 8로 얹는다 */} -
- +
+ {/* Figma 그림 자리 81px(size-20.25). 그림(71×70.109)은 SVG 자체 크기로 가운데 둔다 */} +
+
@@ -44,17 +43,20 @@ function EventsEmptyState({ align="center" color="semantic.label.alternative" variant="label1" - weight="medium" + weight={descriptionWeight} > {description}
- {actionLabel && ( - // 아카이빙 화면이 아직 없어서 동작은 보류다. - // TODO: 아카이빙 목록 화면(billilge/stream-client-web#31)이 생기면 onClick을 연결한다 - )}
diff --git a/src/features/events/components/EventsList.tsx b/src/features/events/components/EventsList.tsx index bbc744c..f9a4df2 100644 --- a/src/features/events/components/EventsList.tsx +++ b/src/features/events/components/EventsList.tsx @@ -11,7 +11,7 @@ import EventsEmptyState from "@/features/events/components/EventsEmptyState"; // 버튼은 Figma가 지정한 모집중에만 노출한다 — 모집종료 필터에서 "지난 행사를 보세요"는 모순이 된다. const EMPTY_STATE_BY_FILTER: Record< string, - { title: string; description: string; actionLabel?: string } + { title: string; description: string; hasArchivesAction?: boolean } > = { all: { description: "새로운 행사가 열리면 알려드릴게요", @@ -22,7 +22,7 @@ const EMPTY_STATE_BY_FILTER: Record< title: "지난 행사가 없어요", }, open: { - actionLabel: "아카이빙 둘러보기", + hasArchivesAction: true, description: "지난 행사의 활동을 확인해 보세요", title: "모집 중인 행사가 없어요", }, @@ -36,11 +36,17 @@ interface EventsListProps { statusFilter: string; onSelect: (eventId: string) => void; onApply: (eventId: string) => void; + onBrowseArchives: () => void; } // 행사 목록 데이터를 받아 그리는 부분. 데이터를 받는 동안은 EventsListScreen의 Suspense가 // EventsListSkeleton을 보여준다. 이동 같은 화면 동작은 Screen이 콜백으로 넘긴다. -function EventsList({ statusFilter, onSelect, onApply }: EventsListProps) { +function EventsList({ + statusFilter, + onSelect, + onApply, + onBrowseArchives, +}: EventsListProps) { const events = use(fetchEvents()); // 빌릴게 카테고리 필터와 달리 모집 상태는 목데이터에 이미 들어있어서 실제로 걸러낼 수 있다. @@ -50,14 +56,21 @@ function EventsList({ statusFilter, onSelect, onApply }: EventsListProps) { : events.filter((event) => event.status === statusFilter); if (visibleEvents.length === 0) { + const emptyState = + EMPTY_STATE_BY_FILTER[statusFilter] ?? EMPTY_STATE_BY_FILTER.all; return ( // Figma는 Empty State를 목록 영역(헤더·Bottom Nav 사이) 가운데에 둔다. // 카드 목록과 같은 gap-6 래퍼 안에 넣으면 flex-1이 높이를 못 받아 위에 붙어버려서, // 빈 목록일 때는 래퍼를 대체해 h-full로 가운데 정렬한다.
); diff --git a/src/features/events/components/EventsQuestionField.tsx b/src/features/events/components/EventsQuestionField.tsx index 917555f..3b1a8f8 100644 --- a/src/features/events/components/EventsQuestionField.tsx +++ b/src/features/events/components/EventsQuestionField.tsx @@ -2,29 +2,56 @@ // (nodeId 1658:183407, 1658:183418, 1658:183424), 기타 입력칸은 Other Option (nodeId 1658:183954) import { Checkbox, + css, RadioGroup, RadioGroupItem, TextArea, TextAreaContent, Typography, + typographyStyle, } from "@wanteddev/wds"; import { useId } from "react"; +import type { + EventsAnswer, + EventsChoiceAnswer, + EventsQuestion, +} from "@/entities/events/types"; import { EVENTS_OTHER_MAX_LENGTH, EVENTS_OTHER_OPTION_LABEL, EVENTS_TEXT_MAX_LENGTH, - type EventsAnswer, - type EventsChoiceAnswer, - type EventsQuestion, } from "@/features/events/constants/eventsApplication"; -interface EventsQuestionFieldProps { +interface EventsQuestionFieldBaseProps { question: EventsQuestion; answer: EventsAnswer | undefined; - onAnswerChange: (answer: EventsAnswer) => void; + /** 취소된 신청내역처럼 입력들을 흐리게(비활성) 보여 준다 */ + disabled?: boolean; } +// 읽기 전용은 readOnly로 명시한다. 입력 폼은 onAnswerChange가 필수라, 콜백을 빠뜨리면 +// 조용히 읽기 전용 폼이 되지 않고 타입 검사에서 걸린다. +type EventsQuestionFieldProps = EventsQuestionFieldBaseProps & + ( + | { + /** 신청내역 상세처럼 제출한 답변을 보여 주기만 한다 */ + readOnly: true; + onAnswerChange?: never; + } + | { + readOnly?: false; + onAnswerChange: (answer: EventsAnswer) => void; + } + ); + +const TEXT_AREA_TYPOGRAPHY = css` + & textarea, + & textarea::placeholder { + ${typographyStyle("label1-reading", "regular")} + } +`; + const EMPTY_CHOICE_ANSWER: EventsChoiceAnswer = { otherText: "", selected: [] }; function toChoiceAnswer(answer: EventsAnswer | undefined): EventsChoiceAnswer { @@ -34,11 +61,20 @@ function toChoiceAnswer(answer: EventsAnswer | undefined): EventsChoiceAnswer { // 문항 유형(question.type)별로 컴포넌트를 나누지 않고 이 컴포넌트 하나가 유형에 맞는 입력을 그린다. // 제목 옆 "*"는 WDS `Label required`를 쓰지 않았다 — Figma의 Field Label은 WDS 인스턴스가 아닌 // 로컬 텍스트이고, `*`도 Body 1/Bold(16px)라 Label이 그리는 label1/medium(14px)과 크기가 다르다. +// +// 신청내역 상세(1133:46181)는 같은 카드를 readOnly로 쓴다 — 선택 표시는 그대로 두고 바꿀 수만 없다. +// 신청취소 상세(1133:46216)는 입력들이 흐린 비활성 모양이라 disabled를 넘긴다. function EventsQuestionField({ question, answer, onAnswerChange, + disabled = false, + readOnly: isReadOnly = false, }: EventsQuestionFieldProps) { + // 비활성(취소된 신청)이면 선택지 글자도 Label/Disable로 흐리게 한다(Figma 1133:46240) + const optionColor = disabled + ? "semantic.label.disable" + : "semantic.label.normal"; const id = useId(); const titleId = `${id}-title`; const otherId = `${id}-other`; @@ -56,7 +92,7 @@ function EventsQuestionField({ option === EVENTS_OTHER_OPTION_LABEL && !checked ? "" : choiceAnswer.otherText; - return onAnswerChange({ otherText, selected }); + return onAnswerChange?.({ otherText, selected }); }; return ( @@ -98,15 +134,22 @@ function EventsQuestionField({ return (
toggleOption(option, checked)} + onCheckedChange={ + isReadOnly + ? undefined + : (checked) => toggleOption(option, checked) + } size="small" />