diff --git a/docs/conventions/wds-component-usage.md b/docs/conventions/wds-component-usage.md index cd97a6b..537641f 100644 --- a/docs/conventions/wds-component-usage.md +++ b/docs/conventions/wds-component-usage.md @@ -653,7 +653,7 @@ Figma는 헤더 배경이 없어서 배너 그림자(`Shadow/Spread/Small`, 60px | 빈 상태 버튼 `열린피드백 작성하기` / `열린피드백 보러 가기` | `Button color="assistive" size="small" variant="solid"` | 행사 목록 빈 상태(outlined)와 variant가 다르다 | | 뒤로가기 헤더 | `ScreenHeader variant="normal"` + `TopNavigationButton` | `useBackNavigation(fallbackPath)`(`hooks/`) — 바로 들어와 뒤로 갈 기록이 없으면 홈(상세는 목록, 위치 보기는 배정 상태)으로 보낸다. 대비 경로는 각 기능 `constants`의 경로 상수(`FEE_PAYMENTS_PATH`·`MY_FEEDBACKS_PATH`·`LOCKER_ASSIGNMENT_PATH` 등)를 쓰고, 데이터가 없을 때의 ``도 화면이 넘긴 `fallbackPath`로 간다 | -- **카드·빈 상태는 Stream 로컬**: 작성내역 카드(`FeedbacksHistoryCard`), 납부 카드(`FeePaymentCard`), 배정 카드는 흰 바탕 `rounded-xl p-4` 로컬 카드다. 빈 상태는 행사 목록 Empty State와 같은 이유(`FallbackView`의 일러스트 크기·패딩 고정)로 로컬이다. 열린피드백은 `FeedbacksEmptyState`(그림 `icons/feedbacks/empty-feedback.svg`)를 쓰고, 사물함은 `LockersEmptyState`(그림은 행사 목록과 같은 `icons/common/empty-events.svg`)를 쓴다. +- **카드·빈 상태는 Stream 로컬**: 작성내역 카드(`FeedbacksHistoryCard`)와 학생회비 확인 내용 카드, 배정된 사물함 카드는 흰 바탕 `rounded-xl p-4` 로컬 카드다. 학생회비 납부 요약과 사물함 배정 요약(배지 + 제목 + 라벨/값 행)은 행사 신청내역 상세 요약과 같은 공용 `StatusSummaryCard`(`components/ui/`)를 쓴다 — 행 내용은 각 기능(`FeePaymentCard`·`LockersAssignmentContent`)이 만들어 넘긴다. 행 간격은 6px로, Figma 학생회비 카드(`3147:147860`)만 8px로 그려진 건 디자인 실수로 보고 맞췄다(#120). 로딩 중에는 같은 파일의 `StatusSummaryCardSkeleton`(행 수만큼 `rowWidths`)이 카드와 같은 높이·간격으로 자리를 채운다. 빈 상태는 행사 목록 Empty State와 같은 이유(`FallbackView`의 일러스트 크기·패딩 고정)로 로컬이다. 열린피드백은 `FeedbacksEmptyState`(그림 `icons/feedbacks/empty-feedback.svg`)를 쓰고, 사물함은 `LockersEmptyState`(그림은 행사 목록과 같은 `icons/common/empty-events.svg`)를 쓴다. - **답변 강조 표시는 두 상세 화면이 공유한다**: 답변 글의 `**굵게**`를 볼드 `Typography`로 바꾸는 `FeedbacksAnswerText`를 모아보기 상세와 작성내역 상세가 같이 쓴다. - **사물함 위치 보기(`/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` 하이픈 표기다. @@ -669,7 +669,7 @@ Figma는 헤더 배경이 없어서 배너 그림자(`Shadow/Spread/Small`, 60px | 취소 완료 토스트 | `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`(아이콘 + 일시·장소)와 달리 "행사·신청·취소" 라벨 행이라 따로 뒀다. +- **카드·요약은 Stream 로컬**: 신청내역 카드(`EventsApplicationCard`, 70px 회색 썸네일 + 배지·제목·일시 + 버튼 2개)는 행사 로컬이다. 상세 요약은 공용 `StatusSummaryCard`이고, `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에 없어 임의로 정했다. diff --git a/src/components/ui/StatusSummaryCard.tsx b/src/components/ui/StatusSummaryCard.tsx new file mode 100644 index 0000000..3a56952 --- /dev/null +++ b/src/components/ui/StatusSummaryCard.tsx @@ -0,0 +1,114 @@ +import { ContentBadge, Skeleton, Typography } from "@wanteddev/wds"; +import type { ReactNode } from "react"; + +import { useSkeletonAnimation } from "@/components/ui/ScreenSkeleton"; + +export interface StatusSummaryCardRow { + label: string; + value: string; +} + +interface StatusSummaryCardProps { + /** 상태 배지 — 상태별 문구·색은 각 기능 constants의 배지 매핑에서 골라 넘긴다 */ + badge: { label: string; color: "green" | "orange" | "red" }; + title: ReactNode; + rows: StatusSummaryCardRow[]; +} + +// Figma: 신청내역 상세 Event Summary (nodeId 1133:46188), 학생회비 납부내역 (3147:147860), +// 사물함 배정 상태 (3147:148357) — 같은 모양의 Stream 로컬 카드라 하나로 모았다. +// 흰 카드 안에 상태 배지 → 제목 → 라벨/값 행. 행을 무엇으로 채울지는 쓰는 쪽이 정한다. +// 행 사이는 6px다. Figma 학생회비 카드만 8px로 그려져 있는데 디자인 실수로 보고 맞췄다(#120). +function StatusSummaryCard({ badge, title, rows }: StatusSummaryCardProps) { + return ( +
+ + {badge.label} + + + {title} + +
+ {rows.map((row) => ( +
+ + {row.label} + + + {row.value} + +
+ ))} +
+
+ ); +} + +interface StatusSummaryCardSkeletonProps { + /** 행마다 막대 폭 — 행 수만큼 넘긴다 */ + rowWidths: string[]; + badgeWidth?: string; + titleWidth?: string; +} + +// 데이터를 받는 동안 StatusSummaryCard 자리를 채운다 — 배지·제목·행 높이와 간격을 카드와 같게 둬서, +// 데이터가 들어와도 아래 내용이 밀리지 않는다. +export function StatusSummaryCardSkeleton({ + rowWidths, + badgeWidth = "64px", + titleWidth = "60%", +}: StatusSummaryCardSkeletonProps) { + const animation = useSkeletonAnimation(); + + return ( +
+ + +
+ {rowWidths.map((width, index) => ( + + ))} +
+
+ ); +} + +export default StatusSummaryCard; diff --git a/src/features/events/components/EventsApplicationDetailContent.tsx b/src/features/events/components/EventsApplicationDetailContent.tsx index 8c9af93..8dc7608 100644 --- a/src/features/events/components/EventsApplicationDetailContent.tsx +++ b/src/features/events/components/EventsApplicationDetailContent.tsx @@ -35,7 +35,7 @@ function EventsApplicationDetailContent({ return (
-
+
{application.questions.map((question) => ( -
- - - - -
+
+ {QUESTION_SKELETON_KEYS.map((key) => (
- - {badge.label} - - - {application.eventTitle} - -
- {rows.map((row) => ( -
- - {row.label} - - - {row.value} - -
- ))} -
-
+ ); } diff --git a/src/features/fee/components/FeePaymentCard.tsx b/src/features/fee/components/FeePaymentCard.tsx index f595cd9..c0482d7 100644 --- a/src/features/fee/components/FeePaymentCard.tsx +++ b/src/features/fee/components/FeePaymentCard.tsx @@ -1,5 +1,6 @@ -import { Button, ContentBadge, Typography } from "@wanteddev/wds"; +import { Button, Typography } from "@wanteddev/wds"; +import StatusSummaryCard from "@/components/ui/StatusSummaryCard"; import type { FeePayment } from "@/entities/fee/types"; import { FEE_STATUS_BADGES } from "@/features/fee/constants/fee"; import { formatDateTime } from "@/lib/date"; @@ -15,9 +16,9 @@ function formatAmount(value: number | null) { } // Figma: 학생회비 납부내역 납부확인중 (nodeId 3147:147860), 확인필요 (3147:147879), 납부완료 (3147:147841) -// 납부 카드 아래에, 확인필요일 때만 학생회 확인 내용 카드가 붙는다. 둘 다 Stream 로컬 카드다. +// 납부 요약은 공용 StatusSummaryCard이고, 그 아래에 확인필요일 때만 학생회 확인 내용 카드(학생회비 로컬)가 붙는다. +// 행 간격은 Figma(8px)가 아니라 다른 요약 카드와 같은 6px다 — Figma 쪽 실수로 보고 맞췄다(#120). function FeePaymentCard({ payment, onContact }: FeePaymentCardProps) { - const badge = FEE_STATUS_BADGES[payment.status]; const rows = [ { label: "납부금액", value: formatAmount(payment.amount) }, { label: "납부일시", value: formatDateTime(payment.paidAt) }, @@ -29,44 +30,11 @@ function FeePaymentCard({ payment, onContact }: FeePaymentCardProps) { return (
-
- - {badge.label} - - - {payment.title} - - {rows.map((row) => ( -
- - {row.label} - - - {row.value} - -
- ))} -
+ {payment.status === "needs-check" && (
diff --git a/src/features/fee/components/FeePaymentList.tsx b/src/features/fee/components/FeePaymentList.tsx index f29f1d6..083b5ff 100644 --- a/src/features/fee/components/FeePaymentList.tsx +++ b/src/features/fee/components/FeePaymentList.tsx @@ -13,7 +13,7 @@ function FeePaymentList({ onContact }: FeePaymentListProps) { const payments = use(fetchFeePayments()); return ( -
+
{payments.map((payment) => ( -
- - - - - -
+
); } diff --git a/src/features/lockers/components/LockersAssignmentContent.tsx b/src/features/lockers/components/LockersAssignmentContent.tsx index b798fe5..c594e96 100644 --- a/src/features/lockers/components/LockersAssignmentContent.tsx +++ b/src/features/lockers/components/LockersAssignmentContent.tsx @@ -1,7 +1,8 @@ -import { Button, ContentBadge, Typography } from "@wanteddev/wds"; +import { Button, Typography } from "@wanteddev/wds"; import { IconChevronRight } from "@wanteddev/wds-icon"; import { use } from "react"; +import StatusSummaryCard from "@/components/ui/StatusSummaryCard"; import { fetchMyLockerAssignment } from "@/entities/lockers/lockersApi"; import LockersEmptyState from "@/features/lockers/components/LockersEmptyState"; import { LOCKER_STATUS_BADGES } from "@/features/lockers/constants/lockers"; @@ -15,7 +16,7 @@ interface LockersAssignmentContentProps { } // Figma: 사물함 배정 상태 배정완료 (nodeId 3147:148357), 빈 상태 (3147:148331) -// 배정 요약 카드와 배정된 사물함 카드 둘 다 Stream 로컬 카드다(학생회비 납부내역 카드와 같은 틀). +// 배정 요약은 공용 StatusSummaryCard, 배정된 사물함 카드는 사물함 로컬 카드다. function LockersAssignmentContent({ onViewLocation, }: LockersAssignmentContentProps) { @@ -43,47 +44,12 @@ function LockersAssignmentContent({ ]; return ( -
-
- - {ASSIGNED_BADGE.label} - - - {assignment.semester} 사물함 - -
- {rows.map((row) => ( -
- - {row.label} - - - {row.value} - -
- ))} -
-
+
+
diff --git a/src/features/lockers/components/LockersAssignmentSkeleton.tsx b/src/features/lockers/components/LockersAssignmentSkeleton.tsx index 4a40d2d..6eda3f6 100644 --- a/src/features/lockers/components/LockersAssignmentSkeleton.tsx +++ b/src/features/lockers/components/LockersAssignmentSkeleton.tsx @@ -1,40 +1,15 @@ import { Skeleton } from "@wanteddev/wds"; import { useSkeletonAnimation } from "@/components/ui/ScreenSkeleton"; +import { StatusSummaryCardSkeleton } from "@/components/ui/StatusSummaryCard"; // 배정 상태를 받는 동안 보이는 스켈레톤 — 배정 요약 카드(배지 + 제목 + 2줄)와 사물함 카드 배치만 따른다. function LockersAssignmentSkeleton() { const animation = useSkeletonAnimation(); return ( -
-
- - - - -
+
+