Skip to content
Open
4 changes: 2 additions & 2 deletions docs/conventions/wds-component-usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` 등)를 쓰고, 데이터가 없을 때의 `<Navigate>`도 화면이 넘긴 `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`로 넘긴다(신청 `<LockersLegend />`, 위치 보기 `<LockersLegend variant="mine" />` — "내 사물함" 하나). 내 칸은 배정 정보(`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` 하이픈 표기다.
Expand All @@ -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에 없어 임의로 정했다.
Expand Down
114 changes: 114 additions & 0 deletions src/components/ui/StatusSummaryCard.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="flex flex-col items-start gap-2 rounded-xl bg-background-normal p-4">
<ContentBadge
accentColor={`semantic.accent.foreground.${badge.color}`}
color="accent"
size="small"
variant="solid"
>
{badge.label}
</ContentBadge>
<Typography
as="p"
color="semantic.label.normal"
variant="heading2"
weight="bold"
>
{title}
</Typography>
<div className="flex flex-col gap-1.5">
{rows.map((row) => (
<div className="flex gap-2" key={row.label}>
<Typography
as="p"
color="semantic.label.alternative"
variant="label2"
weight="regular"
>
{row.label}
</Typography>
<Typography
as="p"
color="semantic.label.neutral"
variant="label2"
weight="regular"
>
{row.value}
</Typography>
</div>
))}
</div>
</div>
);
}

interface StatusSummaryCardSkeletonProps {
/** 행마다 막대 폭 — 행 수만큼 넘긴다 */
rowWidths: string[];
badgeWidth?: string;
titleWidth?: string;
}

// 데이터를 받는 동안 StatusSummaryCard 자리를 채운다 — 배지·제목·행 높이와 간격을 카드와 같게 둬서,
// 데이터가 들어와도 아래 내용이 밀리지 않는다.
export function StatusSummaryCardSkeleton({
rowWidths,
badgeWidth = "64px",
titleWidth = "60%",
}: StatusSummaryCardSkeletonProps) {
const animation = useSkeletonAnimation();

return (
<div className="flex flex-col gap-2 rounded-xl bg-background-normal p-4">
<Skeleton
animation={animation}
height="24px"
radius="6px"
variant="rectangle"
width={badgeWidth}
/>
<Skeleton
animation={animation}
height="28px"
variant="text"
width={titleWidth}
/>
<div className="flex flex-col gap-1.5">
{rowWidths.map((width, index) => (
<Skeleton
animation={animation}
height="18px"
// biome-ignore lint/suspicious/noArrayIndexKey: 행 수가 고정된 자리 표시라 순서가 바뀌지 않는다
key={index}
variant="text"
width={width}
/>
))}
</div>
</div>
);
}

export default StatusSummaryCard;
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ function EventsApplicationDetailContent({

return (
<div className="flex flex-col items-center gap-6 pb-6">
<div className="flex w-full flex-col gap-3 px-5">
<div className="flex w-full flex-col gap-2 px-5">
<EventsApplicationSummary application={application} />
{application.questions.map((question) => (
<EventsQuestionField
Expand Down
31 changes: 3 additions & 28 deletions src/features/events/components/EventsApplicationDetailSkeleton.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { Skeleton } from "@wanteddev/wds";

import { useSkeletonAnimation } from "@/components/ui/ScreenSkeleton";
import { StatusSummaryCardSkeleton } from "@/components/ui/StatusSummaryCard";

const QUESTION_SKELETON_KEYS = ["question-1", "question-2", "question-3"];

Expand All @@ -9,34 +10,8 @@ function EventsApplicationDetailSkeleton() {
const animation = useSkeletonAnimation();

return (
<div className="flex flex-col gap-3 px-5">
<div className="flex flex-col gap-2 rounded-xl bg-background-normal p-4">
<Skeleton
animation={animation}
height="24px"
radius="6px"
variant="rectangle"
width="56px"
/>
<Skeleton
animation={animation}
height="28px"
variant="text"
width="60%"
/>
<Skeleton
animation={animation}
height="18px"
variant="text"
width="75%"
/>
<Skeleton
animation={animation}
height="18px"
variant="text"
width="45%"
/>
</div>
<div className="flex flex-col gap-2 px-5">
<StatusSummaryCardSkeleton badgeWidth="56px" rowWidths={["75%", "45%"]} />
{QUESTION_SKELETON_KEYS.map((key) => (
<div
className="flex flex-col gap-3 rounded-xl bg-background-normal p-4"
Expand Down
54 changes: 9 additions & 45 deletions src/features/events/components/EventsApplicationSummary.tsx
Original file line number Diff line number Diff line change
@@ -1,17 +1,16 @@
import { ContentBadge, Typography } from "@wanteddev/wds";

import StatusSummaryCard from "@/components/ui/StatusSummaryCard";
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(아이콘 + 일시·장소)와 달리 "행사·신청·취소" 라벨 행이라 따로 둔다.
// Figma: 신청내역 상세 Event Summary (nodeId 1133:46188), 신청취소 상세 (1133:46223).
// 신청내역에서 "행사·신청·(취소)" 행을 만들어 공용 StatusSummaryCard에 넘긴다 — 취소 행은 취소된 신청에만 있다.
// 신청 폼·완료 화면의 EventsSummaryCard(아이콘 + 일시·장소)와는 다른 카드다.
function EventsApplicationSummary({
application,
}: {
application: MyEventApplication;
}) {
const badge = EVENTS_APPLICATION_STATUS_BADGES[application.status];
const rows = [
{
label: "행사",
Expand All @@ -29,46 +28,11 @@ function EventsApplicationSummary({
];

return (
<div className="flex flex-col items-start gap-2 rounded-xl bg-background-normal p-4">
<ContentBadge
accentColor={`semantic.accent.foreground.${badge.color}`}
color="accent"
size="small"
variant="solid"
>
{badge.label}
</ContentBadge>
<Typography
as="p"
color="semantic.label.normal"
variant="heading2"
weight="bold"
>
{application.eventTitle}
</Typography>
<div className="flex flex-col gap-1.5">
{rows.map((row) => (
<div className="flex gap-2" key={row.label}>
<Typography
as="p"
color="semantic.label.alternative"
variant="label2"
weight="regular"
>
{row.label}
</Typography>
<Typography
as="p"
color="semantic.label.neutral"
variant="label2"
weight="regular"
>
{row.value}
</Typography>
</div>
))}
</div>
</div>
<StatusSummaryCard
badge={EVENTS_APPLICATION_STATUS_BADGES[application.status]}
rows={rows}
title={application.eventTitle}
/>
);
}

Expand Down
50 changes: 9 additions & 41 deletions src/features/fee/components/FeePaymentCard.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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) },
Expand All @@ -29,44 +30,11 @@ function FeePaymentCard({ payment, onContact }: FeePaymentCardProps) {

return (
<div className="flex flex-col gap-2">
<div className="flex flex-col items-start gap-2 rounded-xl bg-background-normal p-4">
<ContentBadge
accentColor={`semantic.accent.foreground.${badge.color}`}
color="accent"
size="small"
variant="solid"
>
{badge.label}
</ContentBadge>
<Typography
as="p"
color="semantic.label.normal"
variant="heading2"
weight="bold"
>
{payment.title}
</Typography>
{rows.map((row) => (
<div className="flex gap-2" key={row.label}>
<Typography
as="p"
color="semantic.label.alternative"
variant="label2"
weight="regular"
>
{row.label}
</Typography>
<Typography
as="p"
color="semantic.label.neutral"
variant="label2"
weight="regular"
>
{row.value}
</Typography>
</div>
))}
</div>
<StatusSummaryCard
badge={FEE_STATUS_BADGES[payment.status]}
rows={rows}
title={payment.title}
/>

{payment.status === "needs-check" && (
<div className="flex flex-col gap-3 rounded-xl bg-background-normal p-4">
Expand Down
2 changes: 1 addition & 1 deletion src/features/fee/components/FeePaymentList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ function FeePaymentList({ onContact }: FeePaymentListProps) {
const payments = use(fetchFeePayments());

return (
<div className="flex flex-col gap-3 px-5 pb-4">
<div className="flex flex-col gap-2 px-5 pb-4">
{payments.map((payment) => (
<FeePaymentCard
key={payment.id}
Expand Down
43 changes: 5 additions & 38 deletions src/features/fee/components/FeePaymentsSkeleton.tsx
Original file line number Diff line number Diff line change
@@ -1,46 +1,13 @@
import { Skeleton } from "@wanteddev/wds";

import { useSkeletonAnimation } from "@/components/ui/ScreenSkeleton";
import { StatusSummaryCardSkeleton } from "@/components/ui/StatusSummaryCard";

// 납부내역을 받는 동안 보이는 스켈레톤 — 납부 카드(배지 + 제목 + 3줄) 배치만 따른다.
function FeePaymentsSkeleton() {
const animation = useSkeletonAnimation();

return (
<div className="flex flex-col px-5">
<div className="flex flex-col gap-2 rounded-xl bg-background-normal p-4">
<Skeleton
animation={animation}
height="24px"
radius="6px"
variant="rectangle"
width="64px"
/>
<Skeleton
animation={animation}
height="28px"
variant="text"
width="70%"
/>
<Skeleton
animation={animation}
height="18px"
variant="text"
width="50%"
/>
<Skeleton
animation={animation}
height="18px"
variant="text"
width="60%"
/>
<Skeleton
animation={animation}
height="18px"
variant="text"
width="50%"
/>
</div>
<StatusSummaryCardSkeleton
rowWidths={["50%", "60%", "50%"]}
titleWidth="70%"
/>
</div>
);
}
Expand Down
Loading