diff --git a/docs/conventions/wds-component-usage.md b/docs/conventions/wds-component-usage.md index def0347..68031fa 100644 --- a/docs/conventions/wds-component-usage.md +++ b/docs/conventions/wds-component-usage.md @@ -120,6 +120,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`에서 잡혔다. - **`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` 타이포그래피 참고). @@ -609,3 +610,19 @@ Figma는 헤더 배경이 없어서 배너 그림자(`Shadow/Spread/Small`, 60px ### 빈 상태는 WDS `FallbackView`가 아니라 홈 로컬 `HomeEmptyState` 일러스트 44px + 문구 한 줄 + 작은 버튼이라 행사 목록 Empty State 때와 같은 이유(`FallbackView`의 일러스트 폭·패딩 고정)로 맞지 않는다. 그림은 빌릴게 반납 빈 상태(`icons/bililge-empty/return-items.svg`)·행사 목록 빈 상태(`icons/events/empty-events.svg`)와 같은 도형이라 크기만 줄여 그대로 쓴다. + +## 행사 신청내역(`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`로 덮는다. outlined primary가 SemiBold 글자라 primary를 고른다 | +| 신청취소 확인모달 | `ConfirmModal tone="negative"` | 사물함 신청 확인 모달과 같은 공용 모달이고, `tone="negative"`가 느낌표 아이콘 + 빨간 확인 버튼을 그린다 | +| 취소 완료 토스트 | `ScreenToast variant="positive"` | 체크 아이콘이 WDS 기본값 그대로 | +| 상세 문항 `Control/Checkbox`·`Control/Radio`·`Textinput/Textarea` | 신청 폼의 `EventsQuestionField` 재사용 | 답변 변경 콜백을 넘기지 않으면 읽기 전용(선택 표시는 그대로, 바꿀 수 없음). 신청취소 상세는 `disabled`로 WDS 비활성 모양을 쓰고, 선택지 글자는 `Label/Disable`(Figma `1133:46240`) | + +- **카드·요약은 Stream 로컬**: 신청내역 카드(`EventsApplicationCard`, 70px 회색 썸네일 + 배지·제목·일시 + 버튼 2개)와 상세 요약(`EventsApplicationSummary`)은 로컬이다. 요약은 신청 폼·완료 화면의 `EventsSummaryCard`(아이콘 + 일시·장소)와 달리 "행사·신청·취소" 라벨 행이라 따로 뒀다. +- **빈 상태는 `EventsEmptyState` 재사용**: 그림·버튼 모양은 행사 목록 빈 상태와 같고, 설명 굵기만 다르다(행사 목록 Medium, 신청내역 Regular) — `descriptionWeight`로 고른다. 위치는 목록 영역 가운데가 아니라 Figma대로 토글 아래 176px이다. +- **탭은 경로로 나눈다**: 행사 `/events`, 신청내역 `/events/applications`, 상세 `/events/applications/:applicationId`. 같은 목록 화면이 주소로 탭을 고르고, 탭을 바꿀 때는 replace라 히스토리가 쌓이지 않는다. 신청내역 탭은 Figma에 상태 필터 행이 없어서 숨긴다. 하단 탭은 `/events/applications`에서도 "행사"가 선택된다. +- **취소는 목록까지 반영된다**: 취소 API(`cancelMyEventApplication`)가 목데이터를 바꾸고 목록·상세 조회 캐시를 지운다. 상세는 다시 그리는 업데이트를 transition으로 묶어 새 데이터를 받는 동안 스켈레톤 대신 지금 화면을 유지하고, 토스트는 취소된 화면과 함께 뜬다. 실패 토스트 문구("신청 취소에 실패했어요. 다시 시도해 주세요.")는 Figma에 없어 임의로 정했다. diff --git a/src/app/router.tsx b/src/app/router.tsx index b1b3b4d..ac77c8b 100644 --- a/src/app/router.tsx +++ b/src/app/router.tsx @@ -9,6 +9,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"; @@ -47,6 +48,18 @@ const routes = [ handle: { background: "normal" } satisfies ScreenRouteHandle, path: "/events", }, + { + element: , + // 행사 목록의 신청내역 탭 — 같은 화면이 주소로 탭을 고른다 + handle: { background: "normal" } satisfies ScreenRouteHandle, + path: "/events/applications", + }, + { + element: , + // 하단 탭 없이 회색 배경(기본값) — 흰 카드가 뜬다 + handle: { hasBottomNav: false } satisfies ScreenRouteHandle, + path: "/events/applications/:applicationId", + }, { element: , // 카드 없이 구분선으로만 나뉘는 목록이라 화면 전체가 흰 면이다 diff --git a/src/components/ui/ScreenLayout.tsx b/src/components/ui/ScreenLayout.tsx index dfde652..c40ae99 100644 --- a/src/components/ui/ScreenLayout.tsx +++ b/src/components/ui/ScreenLayout.tsx @@ -32,6 +32,11 @@ function getBottomNavValueFromPath(pathname: string): BottomNavValue { if (pathname.startsWith("/feedbacks")) { return "board"; } + // 행사 목록의 신청내역 탭(/events/applications)도 "행사" 탭 소속이다. + // 경로는 features/events의 EVENTS_TAB_PATHS와 같지만, 공용 UI가 기능 폴더를 import하지 않도록 여기 그대로 적는다. + if (pathname === "/events/applications") { + return "event"; + } return "home"; } diff --git a/src/entities/events/eventsApi.ts b/src/entities/events/eventsApi.ts index 434f203..491733c 100644 --- a/src/entities/events/eventsApi.ts +++ b/src/entities/events/eventsApi.ts @@ -1,6 +1,13 @@ -import { EVENTS } from "@/entities/events/eventsMock"; -import type { EventItem } from "@/entities/events/types"; -import { mockupApi } from "@/lib/mockupApi"; +import { + EVENTS, + MY_EVENT_APPLICATIONS_MOCK, +} from "@/entities/events/eventsMock"; +import type { EventItem, MyEventApplication } from "@/entities/events/types"; +import { + invalidateMockupApi, + mockupApi, + mockupMutation, +} from "@/lib/mockupApi"; // 실 API가 붙으면 함수 안쪽만 요청 코드로 바꾼다 — 화면은 이 함수로만 데이터를 받는다. export function fetchEvents(): Promise { @@ -14,3 +21,55 @@ export function fetchEvent(eventId: string): Promise { () => EVENTS.find((event) => event.id === eventId) ?? null, ); } + +// "2026-06-04T13:00" — 목데이터의 일시 형식(분 단위, 현지 시각) +function toLocalDateTime(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())}`; +} + +const MY_APPLICATIONS_KEY = "events/applications"; + +export function fetchMyEventApplications(): Promise { + return mockupApi(MY_APPLICATIONS_KEY, () => [...MY_EVENT_APPLICATIONS_MOCK]); +} + +// 없는 신청내역은 null +export function fetchMyEventApplication( + applicationId: string, +): Promise { + return mockupApi( + `${MY_APPLICATIONS_KEY}/${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: toLocalDateTime(new Date()), + status: "cancelled", + }; + MY_EVENT_APPLICATIONS_MOCK[index] = next; + return next; + }); + invalidateMockupApi([ + MY_APPLICATIONS_KEY, + `${MY_APPLICATIONS_KEY}/${applicationId}`, + ]); + return cancelled; +} diff --git a/src/entities/events/eventsMock.ts b/src/entities/events/eventsMock.ts index 653f22c..d240c3f 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,89 @@ 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 : []; 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/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..aad8882 --- /dev/null +++ b/src/features/events/EventsApplicationDetailScreen.tsx @@ -0,0 +1,118 @@ +import { TopNavigationButton } from "@wanteddev/wds"; +import { IconChevronLeft } from "@wanteddev/wds-icon"; +import { Suspense, startTransition, useState } from "react"; +import { useNavigate, 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 { cancelMyEventApplication } from "@/entities/events/eventsApi"; +import EventsApplicationDetailContent from "@/features/events/components/EventsApplicationDetailContent"; +import EventsApplicationDetailSkeleton from "@/features/events/components/EventsApplicationDetailSkeleton"; +import { EVENTS_TAB_PATHS } from "@/features/events/constants/events"; + +type CancelToast = "cancelled" | "failed"; + +// Figma: 신청내역 상세 (nodeId 1133:46181), 신청취소 확인모달 (1133:46131), 신청취소 상세 + 토스트 (1133:46216) +function EventsApplicationDetailScreen() { + const { applicationId = "" } = useParams(); + const navigate = useNavigate(); + const [isConfirmOpen, setIsConfirmOpen] = useState(false); + const [isCancelling, setIsCancelling] = useState(false); + const [toast, setToast] = useState(null); + // 토스트가 떠 있는 동안 다시 띄워도 새로 안내되게 ScreenToast의 key로 쓴다 + const [toastCount, setToastCount] = useState(0); + + const showToast = (next: CancelToast) => { + setToast(next); + setToastCount((count) => count + 1); + }; + + const cancelApplication = async () => { + setIsConfirmOpen(false); + setIsCancelling(true); + try { + await cancelMyEventApplication(applicationId); + // 취소 API가 조회 캐시를 지워서, 다시 그리면 상세가 취소된 신청내역을 새로 받는다. + // 그 동안 스켈레톤 대신 지금 화면을 유지하도록 다시 그리는 업데이트를 전부 transition으로 묶는다 + // (토스트는 취소된 화면과 함께 뜬다). + startTransition(() => { + setIsCancelling(false); + showToast("cancelled"); + }); + } catch (error) { + console.error(error); + setIsCancelling(false); + showToast("failed"); + } + }; + + // 바로 들어와 뒤로 갈 히스토리가 없으면 신청내역 목록으로 보낸다 + const goBack = () => { + const historyIndex = (window.history.state as { idx?: number } | null)?.idx; + if (historyIndex !== undefined && historyIndex > 0) { + navigate(-1); + return; + } + navigate(EVENTS_TAB_PATHS.application, { replace: true }); + }; + + useScreenHeader( + + + + } + title="신청 상세" + variant="normal" + />, + ); + + return ( + + }> + setIsConfirmOpen(true)} + /> + + + setIsConfirmOpen(false)} + onConfirm={cancelApplication} + open={isConfirmOpen} + title="신청을 취소할까요?" + tone="negative" + /> + + { + if (!open) { + setToast(null); + } + }} + open={toast !== null} + variant={toast === "failed" ? "negative" : "positive"} + /> + + ); +} + +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 ca44c7d..5aa9d51 100644 --- a/src/features/events/EventsListScreen.tsx +++ b/src/features/events/EventsListScreen.tsx @@ -2,24 +2,31 @@ import { SegmentedControl, SegmentedControlItem, TopNavigationButton, - Typography, } from "@wanteddev/wds"; import { IconBell, IconSearch } from "@wanteddev/wds-icon"; import { Suspense, useState } from "react"; -import { useNavigate } from "react-router-dom"; +import { useLocation, useNavigate } from "react-router-dom"; import FilterChipGroup from "@/components/ui/FilterChipGroup"; import ScreenHeader from "@/components/ui/ScreenHeader"; import { useScreenHeader } from "@/components/ui/useScreenHeader"; +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 { getSearchPath } from "@/features/search/constants/search"; // Figma: 행사 (nodeId 1243:70854) function EventsListScreen() { const navigate = useNavigate(); - const [tab, setTab] = useState("event"); + const { pathname } = useLocation(); + const tab: EventsTab = + pathname === EVENTS_TAB_PATHS.application ? "application" : "event"; const [statusFilter, setStatusFilter] = useState("all"); useScreenHeader( @@ -49,7 +56,14 @@ function EventsListScreen() { Figma Tool 프레임(56~88)은 높이 32에 위아래 여백이 없다 — 세로 패딩을 주면 토글과 그 아래 필터 행이 함께 밀린다. */} - + + navigate(EVENTS_TAB_PATHS[value as EventsTab], { replace: true }) + } + size="small" + value={tab} + > 행사 신청내역 @@ -57,14 +71,16 @@ function EventsListScreen() { - {/* 필터는 고정하고 목록만 스크롤된다 */} - - - + {/* 필터는 고정하고 목록만 스크롤된다. 신청내역 탭은 Figma(1133:46168)에 필터 행이 없다 */} + {tab === "event" && ( + + + + )} {/* 헤더·토글·필터는 데이터와 무관해서 바로 그리고, 데이터를 받는 목록 자리만 스켈레톤으로 채운다 */} @@ -81,15 +97,22 @@ function EventsListScreen() { /> ) : ( - - - 신청내역은 아직 준비 중이에요 - - + }> + + 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..3f98e6e --- /dev/null +++ b/src/features/events/components/EventsApplicationCard.tsx @@ -0,0 +1,95 @@ +import { Button, ContentBadge, Typography } from "@wanteddev/wds"; + +import type { MyEventApplication } from "@/entities/events/types"; +import { + EVENTS_APPLICATION_STATUS_BADGES, + formatApplicationDateTime, +} from "@/features/events/constants/events"; + +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" + ? `${formatApplicationDateTime(application.cancelledAt)} 취소` + : `${formatApplicationDateTime(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..fdc5f73 --- /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 { fetchMyEventApplication } from "@/entities/events/eventsApi"; +import EventsApplicationSummary from "@/features/events/components/EventsApplicationSummary"; +import EventsQuestionField from "@/features/events/components/EventsQuestionField"; +import { EVENTS_TAB_PATHS } from "@/features/events/constants/events"; + +interface EventsApplicationDetailContentProps { + applicationId: string; + /** 신청 취소 요청 중에는 버튼을 막아 두 번 보내지 않게 한다 */ + isCancelling: boolean; + onCancel: () => void; +} + +// Figma: 신청내역 상세 (nodeId 1133:46181), 신청취소 상세 (1133:46216) +// 신청 당시 문항에 내가 낸 답변을 채워 읽기 전용으로 보여 준다. 취소된 신청은 입력들을 흐리게 한다. +function EventsApplicationDetailContent({ + applicationId, + isCancelling, + onCancel, +}: EventsApplicationDetailContentProps) { + const application = use(fetchMyEventApplication(applicationId)); + + // 없는 신청내역이면 볼 내용이 없어서 신청내역 목록으로 돌려보낸다 + if (!application) { + return ; + } + + const isCancelled = application.status === "cancelled"; + + return ( + + + + {application.questions.map((question) => ( + + ))} + + {/* Figma 신청취소 상세(1133:46216)에는 버튼이 없다 — 신청완료일 때만 보인다. + WDS Button 색은 primary/assistive뿐이라 outlined primary(SemiBold 글자)에 글자·테두리 색만 + Status/Negative로 덮는다(ConfirmModal의 위험 버튼과 같은 방식). */} + {!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..1b2ce20 --- /dev/null +++ b/src/features/events/components/EventsApplicationList.tsx @@ -0,0 +1,58 @@ +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..7ee4145 --- /dev/null +++ b/src/features/events/components/EventsApplicationSummary.tsx @@ -0,0 +1,77 @@ +import { ContentBadge, Typography } from "@wanteddev/wds"; + +import type { MyEventApplication } from "@/entities/events/types"; +import { + EVENTS_APPLICATION_STATUS_BADGES, + formatApplicationDateTime, +} from "@/features/events/constants/events"; + +// 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: `${formatApplicationDateTime(application.eventDateTime)} · ${application.location}`, + }, + { label: "신청", value: formatApplicationDateTime(application.appliedAt) }, + ...(application.status === "cancelled" + ? [ + { + label: "취소", + value: formatApplicationDateTime(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 b37911b..eece401 100644 --- a/src/features/events/components/EventsEmptyState.tsx +++ b/src/features/events/components/EventsEmptyState.tsx @@ -7,6 +7,9 @@ interface EventsEmptyStateProps { description: string; /** 있을 때만 버튼을 노출한다 — Figma는 모집중 필터에만 "아카이빙 둘러보기"를 둔다 */ actionLabel?: string; + onAction?: () => void; + /** 설명 굵기 — 행사 목록 empty(1165:62725)는 Medium, 신청내역 empty(1410:52742)는 Regular다 */ + descriptionWeight?: "medium" | "regular"; } // Figma: 행사 모집중 empty > Empty State (nodeId 1165:62725) @@ -18,16 +21,14 @@ function EventsEmptyState({ title, description, actionLabel, + onAction, + descriptionWeight = "medium", }: EventsEmptyStateProps) { return ( - - {/* Figma는 81px 박스 안에 71×70.109 아트를 left 7 / top 8로 얹는다 */} - - + + {/* Figma 그림 자리 81px(size-20.25). 그림(71×70.109)은 SVG 자체 크기로 가운데 둔다 */} + + @@ -44,16 +45,21 @@ function EventsEmptyState({ align="center" color="semantic.label.alternative" variant="label1" - weight="medium" + weight={descriptionWeight} > {description} {actionLabel && ( - // 아카이빙 화면이 아직 없어서 동작은 보류다. - // TODO: 아카이빙 목록 화면(billilge/stream-client-web#31)이 생기면 onClick을 연결한다 - + // 행사 목록의 "아카이빙 둘러보기"는 아카이빙 화면이 아직 없어서 onAction 없이 둔다. + // TODO: 아카이빙 목록 화면(billilge/stream-client-web#31)이 생기면 연결한다 + {actionLabel} )} diff --git a/src/features/events/components/EventsQuestionField.tsx b/src/features/events/components/EventsQuestionField.tsx index 917555f..754b16f 100644 --- a/src/features/events/components/EventsQuestionField.tsx +++ b/src/features/events/components/EventsQuestionField.tsx @@ -2,29 +2,43 @@ // (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 { question: EventsQuestion; answer: EventsAnswer | undefined; - onAnswerChange: (answer: EventsAnswer) => void; + /** 없으면 읽기 전용 — 신청내역 상세처럼 제출한 답변을 보여 주기만 한다 */ + onAnswerChange?: (answer: EventsAnswer) => void; + /** 취소된 신청내역처럼 입력들을 흐리게(비활성) 보여 준다 */ + disabled?: boolean; } +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 +48,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)는 같은 카드를 읽기 전용으로 쓴다 — 선택 표시는 그대로 두고 바꿀 수만 없다. +// 신청취소 상세(1133:46216)는 입력들이 흐린 비활성 모양이라 disabled를 넘긴다. function EventsQuestionField({ question, answer, onAnswerChange, + disabled = false, }: EventsQuestionFieldProps) { + const isReadOnly = onAnswerChange === undefined; + // 비활성(취소된 신청)이면 선택지 글자도 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 +79,7 @@ function EventsQuestionField({ option === EVENTS_OTHER_OPTION_LABEL && !checked ? "" : choiceAnswer.otherText; - return onAnswerChange({ otherText, selected }); + return onAnswerChange?.({ otherText, selected }); }; return ( @@ -98,15 +121,22 @@ function EventsQuestionField({ return ( toggleOption(option, checked)} + onCheckedChange={ + isReadOnly + ? undefined + : (checked) => toggleOption(option, checked) + } size="small" /> @@ -122,17 +152,22 @@ function EventsQuestionField({ - toggleOption(EVENTS_OTHER_OPTION_LABEL, checked) + onCheckedChange={ + isReadOnly + ? undefined + : (checked) => + toggleOption(EVENTS_OTHER_OPTION_LABEL, checked) } size="small" /> @@ -148,14 +183,16 @@ function EventsQuestionField({ as="input" className="w-full border-primary border-b-[0.7px] pb-0.5 outline-none placeholder:text-label-assistive" color="semantic.label.normal" + disabled={disabled} maxLength={EVENTS_OTHER_MAX_LENGTH} onChange={(event) => - onAnswerChange({ + onAnswerChange?.({ otherText: event.target.value, selected: choiceAnswer.selected, }) } placeholder="기타 내용을 입력해 주세요." + readOnly={isReadOnly} value={choiceAnswer.otherText} variant="label1-reading" weight="regular" @@ -169,6 +206,8 @@ function EventsQuestionField({ {question.type === "singleChoice" && ( @@ -203,10 +242,17 @@ function EventsQuestionField({ onAnswerChange(event.target.value)} - placeholder="메시지를 입력해 주세요." + onChange={(event) => onAnswerChange?.(event.target.value)} + // 읽기 전용에서 답하지 않은 문항은 입력 안내 대신 빈 칸으로 둔다 + placeholder={isReadOnly ? undefined : "메시지를 입력해 주세요."} + readOnly={isReadOnly} + // WDS TextArea는 글자를 Body 1/Reading(16px)으로 고정하는데 Figma 입력 글자·안내는 + // Label 1/Reading(14px)이다(신청 폼 1658:183424, 신청내역 상세 1133:46214). prop으로 바꿀 수 없어서 + // 바깥에서 textarea만 덮는다 — 자동 높이를 재는 숨은 textarea도 같은 글자로 재도록 둘 다 잡는다. + sx={TEXT_AREA_TYPOGRAPHY} trailingContent={ {EVENTS_TEXT_MAX_LENGTH[question.type]} diff --git a/src/features/events/constants/events.ts b/src/features/events/constants/events.ts index 64ac9c9..0e263ca 100644 --- a/src/features/events/constants/events.ts +++ b/src/features/events/constants/events.ts @@ -5,3 +5,23 @@ export const EVENT_STATUS_FILTERS = [ { label: "모집예정", value: "upcoming" }, { label: "모집종료", value: "closed" }, ] as const; + +// 행사 목록의 행사/신청내역 탭은 경로로 나눈다 — 새로고침·뒤로가기에도 탭이 유지되고, +// 신청 완료 화면처럼 다른 화면에서도 신청내역 탭으로 바로 보낼 수 있다. +export const EVENTS_TAB_PATHS = { + application: "/events/applications", + event: "/events", +} as const; + +export type EventsTab = keyof typeof EVENTS_TAB_PATHS; + +// 신청내역 상태 배지 — 목록 카드와 상세가 같이 쓴다 +export const EVENTS_APPLICATION_STATUS_BADGES = { + applied: { color: "green", label: "신청완료" }, + cancelled: { color: "red", label: "신청취소" }, +} as const; + +// "2026-06-04T13:00" → "2026.06.04 13:00" +export function formatApplicationDateTime(value: string) { + return value.replace("T", " ").replaceAll("-", "."); +} diff --git a/src/features/events/constants/eventsApplication.ts b/src/features/events/constants/eventsApplication.ts index e9b4f0b..3b3b7aa 100644 --- a/src/features/events/constants/eventsApplication.ts +++ b/src/features/events/constants/eventsApplication.ts @@ -1,33 +1,8 @@ import filePencil from "@/assets/icons/events/file-pencil.svg"; - -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; +import type { + EventsQuestion, + EventsTextQuestion, +} from "@/entities/events/types"; export interface EventsApplication { eventName: string; diff --git a/src/lib/mockupApi.ts b/src/lib/mockupApi.ts index 17284bd..7a03467 100644 --- a/src/lib/mockupApi.ts +++ b/src/lib/mockupApi.ts @@ -21,3 +21,20 @@ export function mockupApi(key: string, getData: () => T): Promise { cache.set(key, promise); return promise; } + +// 신청 취소처럼 목데이터를 바꾸는 요청에 쓴다. 조회와 같은 지연을 주지만 캐시하지 않는다. +// 바뀐 데이터를 다시 받게 하려면 응답 뒤 관련 조회 key를 invalidateMockupApi로 지운다. +export async function mockupMutation(mutate: () => T): Promise { + if (import.meta.env.DEV) { + await new Promise((resolve) => setTimeout(resolve, MOCK_DELAY_MS)); + } + // async 함수 안이라 mutate가 던진 에러는 거부된 Promise로 돌아간다 + return mutate(); +} + +// 다음 조회가 바뀐 목데이터로 새 Promise를 만들게 캐시를 지운다. +export function invalidateMockupApi(keys: string[]) { + for (const key of keys) { + cache.delete(key); + } +}