Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
ad19585
refactor: Bottom Nav 활성 탭을 화면 state 대신 라우트에서 파생
xeoxxn Sep 9, 2026
6647381
refactor: ScreenLayout을 라우터 레벨로 이동, useScreenHeader 훅 도입
xeoxxn Sep 9, 2026
8730156
feat: 재사용 가능한 ScreenHeader 컴포넌트 분리
xeoxxn Sep 9, 2026
5655e51
refactor: 대여/반납 토글을 헤더에서 화면 요소로 분리, 필터 고정+목록만 스크롤
xeoxxn Sep 9, 2026
983e481
docs: 용어 사전 컨벤션 추가
xeoxxn Sep 9, 2026
bf4693b
refactor: rental → bililge 네이밍 통일
xeoxxn Sep 9, 2026
f5c3691
chore: main에서 컴포넌트 관련 스킬·컨벤션 문서 가져오기
xeoxxn Sep 9, 2026
0047473
feat: 대여 신청 바텀시트 추가(휠 피커로 대여 시작 시간 선택)
xeoxxn Sep 9, 2026
94b12de
fix: 휠 피커·스테퍼 숫자 폭 고정해서 자릿수 바뀌어도 안 밀리게 수정
xeoxxn Sep 9, 2026
b50014c
fix: 휠 피커 선택 줄 뒤 회색 글자가 비쳐서 두꺼워 보이던 문제 수정
xeoxxn Sep 9, 2026
cf90bc0
fix: figma-check 결과 반영 — 시트 상단 여백, 안내 문구 굵기 Figma에 맞춤
xeoxxn Sep 9, 2026
2e6144e
fix: 바텀시트 하단 세이프에어리어에 누락된 홈 인디케이터 바 추가
xeoxxn Sep 9, 2026
da0758b
revert: 홈 인디케이터 바 제거, 세이프에어리어 여백만 유지
xeoxxn Sep 9, 2026
e7887bf
fix: 바텀시트 하단 여백을 34px로 과다 계산했던 것 14px로 수정
xeoxxn Sep 9, 2026
fc643d4
fix: Bottom Nav 하단 검은색 홈 인디케이터 바 제거, 여백만 유지
xeoxxn Sep 9, 2026
d6d9f2b
fix: 바텀시트 슬라이드 애니메이션을 iOS 스타일 감속 곡선으로 자연스럽게 조정
xeoxxn Sep 9, 2026
4142283
perf: 바텀시트 첫 오픈 때 휠 피커 마운트가 슬라이드 첫 프레임을 막던 문제 수정
xeoxxn Sep 10, 2026
acae08d
refactor: 텍스트 스타일을 WDS Typography 컴포넌트로 전환, 타이포그래피 컨벤션 추가
xeoxxn Sep 10, 2026
3b3ec57
refactor: 화면 헤더를 ScreenHeader 하나로 통합(variant/title/leading/trailing p…
xeoxxn Sep 10, 2026
e950892
refactor: 필터 칩을 FilterChipGroup 공용 컴포넌트로 추출
leegain1 Sep 12, 2026
a083d15
feat: ScreenHeader에 toolbar 슬롯 추가
leegain1 Sep 12, 2026
32360c1
feat: 행사 목록 목데이터 추가
leegain1 Sep 12, 2026
a78ef3a
feat: 행사 카드 컴포넌트 추가
leegain1 Sep 12, 2026
b1b221b
feat: 행사 목록 화면 추가 및 라우트 연결
leegain1 Sep 12, 2026
2d39afc
docs: 행사 화면에서 확정된 WDS 매핑 기록
leegain1 Sep 12, 2026
b056425
merge: main 병합 — 라우터 객체 구조·ScreenHeader 로컬 컴포넌트 전환에 맞춤
leegain1 Sep 15, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions docs/conventions/wds-component-usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -152,3 +152,47 @@ WDS `Label`의 `required`는 `*`를 `semantic.status.negative`로 그려서 **
## 완전 확정 방법 (필요 시)

이름 대조가 아니라 100% 확정하려면, 확인하고 싶은 인스턴스의 `nodeId`를 알아낸 뒤 `get_design_context(fileKey, nodeId)`를 호출해 응답에 포함된 componentKey를 위 표의 WDS componentKey와 직접 비교하면 된다. 또는 Figma 앱에서 인스턴스 선택 → 우측 패널 "Instance of" → 라이브러리 아이콘 클릭으로도 즉시 확인 가능하다.

## 행사 목록 화면(`1243:70854`) 구현 중 확정된 매핑

| WDS 컴포넌트 | 코드 export | 확인 내용 |
|---|---|---|
| `Content Badge/Content Badge` | `ContentBadge` | 메인 컴포넌트 Node ID `445:5656` — [문서](https://montage.wanted.co.kr/docs/components/contents/content-badge/design) |
| `Divider/Divider` | `Divider` | `color`에 토큰 문자열을 넘긴다(`color="semantic.line.normal.alternative"`). Figma의 `divider(new)`가 이 값(`rgba(112,115,124,0.08)`, 1px)이라 로컬로 선을 그리지 않고 WDS를 쓴다 |

### `ContentBadge`의 accent/neutral 분기가 Figma 구조와 1:1로 맞는다

Figma의 Content Badge는 상태에 따라 배경 처리 방식이 두 가지다 — "accent 색을 8% 오퍼시티 레이어로 깐 것"(모집중·모집예정)과 "이미 알파가 포함된 `Fill/Normal`을 그대로 쓴 것"(모집종료). 한 가지 방식으로 뭉뚱그리면 색이 틀어지는데, `ContentBadge`의 `color` prop이 정확히 이 둘로 갈린다(`content-badge/style.js`의 `contentBadgeColorStyle`):

- `color="accent"` + `accentColor` → `background: addOpacity(accentColor, opacity[8])`, 글자는 `accentColor` 원색
- `color="neutral"` + `neutralColor` → `background: theme.semantic.fill.normal`, 글자는 `neutralColor`

`size="small"`도 Figma 스펙(`padding: 4px 6px`, `caption1/medium`)과 그대로 일치한다. 실제로 쓴 토큰:

| 상태 | props | 실측 결과 |
|---|---|---|
| 모집중 | `color="accent" accentColor="semantic.accent.foreground.redOrange"` | `rgba(245,90,0,0.08)` / `#F55A00` |
| 모집예정 | `color="accent" accentColor="semantic.accent.foreground.cyan"` | `rgba(0,152,178,0.08)` / `#0098B2` |
| 모집종료 | `color="neutral" neutralColor="semantic.label.alternative"` | `rgba(112,115,124,0.08)` / `rgba(55,56,60,0.61)` |

### `Button`의 `size="small"` 타이포는 버튼이 아니라 `& > span`에 걸린다

`button/style.js`의 `small` 분기는 `& > span { typographyStyle("label2", fontWeight) }` 형태라, `getComputedStyle(button).fontSize`를 재면 상속값 16px이 나오고 실제 글자는 안쪽 span의 13px이다. 검증할 때 버튼 엘리먼트를 재면 틀린 결론이 난다.

또한 행사 카드 CTA는 빌릴게 카드와 달리 `sx` 보정이 필요 없다. Figma의 비활성 상태 색(`Interaction/Disable #F4F4F5` + `Label/Assistive`)이 WDS `Button`의 `&[aria-disabled='true']` 블록과 그대로 같아서 `disabled` prop만 주면 된다.

### 헤더 아래 세그먼트 토글은 `ScreenHeader`의 `toolbar`로 넘긴다

행사 화면 헤더(`1765:70732`)는 **로컬 `Top Navigation`(`1765:70665`, 0~56)** 과 **형제 노드인 `Segmented Control`(`1765:70708`, y=56 x=20 w=335 h=32)** 로 나뉜다. 예전에는 WDS `Top Navigation/Resource/Contents` 하나가 내부 `Tool` 슬롯까지 품은 높이 88짜리 인스턴스였는데, 디자인이 바뀌면서 둘로 분리됐다(`ScreenHeader`의 display variant가 WDS를 떠나 로컬 마크업이 된 것과 같은 변경).

그래서 코드도 WDS `TopNavigation`의 `toolbar` prop에 기대지 않고, `ScreenHeader`가 타이틀 행 아래에 `toolbar`를 그대로 이어 붙인다. 화면 본문에 토글을 두지 않는 이유는 그대로다 — 본문에 두면 헤더 고정 영역 밖이라 스크롤 경계가 화면마다 달라진다.

Tool 영역은 `88 - 56 - 32 = 0`, 즉 **아래 여백이 없다.** 세로 패딩을 주면 헤더가 그만큼 길어진다. 가로는 x=20이라 `px-5`로 맞춘다.

### 필터 칩 반례가 행사 화면에서도 확인됐다

위 "빌릴게 필터 Chip은 WDS `Chip/Chip`이 아니었다" 항목과 같은 결론이다. 행사 화면의 상태 필터 칩(전체/모집중/모집예정/모집종료, `1243:70862`)도 `get_design_context`로 열어보니 원본이 Stream 로컬 컴포넌트 `1016:55355`였고, 스타일도 빌릴게 카테고리 필터와 완전히 동일했다(활성 = `Blue/95` 배경 + `Primary/Normal` 외곽선·글자). 두 화면이 같은 칩을 쓰는 게 확인돼 `src/components/ui/FilterChipGroup.tsx`로 공용화했다.

### 미해결: WDS Navigation 행이 Figma보다 8px 높다

Figma의 `Navigation` 프레임은 56px(패딩 16 + 내부 24)인데, 타이틀 텍스트(32px)가 24px짜리 `Section` 위로 오버플로우되도록 배치돼 있다. WDS `TopNavigation`은 이 32px을 행 높이에 그대로 더해서 64px이 된다. 그 결과 헤더 아래 모든 요소가 8px씩 내려간다. **홈·빌릴게를 포함한 모든 화면에 공통으로 해당**하며(빌릴게 헤더도 실측 64px), 특정 화면에서 고칠 문제가 아니라 `ScreenHeader` 차원에서 판단할 사안이라 별도로 남겨둔다.
2 changes: 2 additions & 0 deletions src/app/router.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import ScreenLayoutRoute, {
} from "@/app/ScreenLayoutRoute";
import BililgeListScreen from "@/features/bililge/BililgeListScreen";
import EventsApplicationScreen from "@/features/events/EventsApplicationScreen";
import EventsListScreen from "@/features/events/EventsListScreen";
import HomeScreen from "@/features/home/HomeScreen";

// 앱의 모든 라우트는 이 객체 배열 한곳에서 정의한다 — 새 화면은 여기에 라우트를 추가한다.
Expand All @@ -19,6 +20,7 @@ const routes = [
children: [
{ element: <HomeScreen />, path: "/" },
{ element: <BililgeListScreen />, path: "/bililge" },
{ element: <EventsListScreen />, path: "/events" },
{
element: <EventsApplicationScreen />,
// Bottom Nav 대신 하단 고정 버튼(Action Area)이 있는 화면
Expand Down
55 changes: 55 additions & 0 deletions src/components/ui/FilterChipGroup.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { Typography } from "@wanteddev/wds";

export interface FilterChipOption {
value: string;
label: string;
}

interface FilterChipGroupProps {
options: readonly FilterChipOption[];
value: string;
onChange: (value: string) => void;
}

// Figma: Filter Chips (빌릴게 1243:73337, 행사 1243:70861) — 원본이 같은 Stream 로컬 칩(1016:55355)이다.
// WDS `Chip`이 아니다: WDS Chip의 활성 스타일은 검정 배경이라 Figma(연한 파랑 배경 + 파랑 outline +
// 파랑 텍스트)와 다르다. wds-component-usage.md "빌릴게 필터 Chip은 WDS Chip/Chip이 아니었다" 참고.
//
// 빌릴게(카테고리)와 행사(모집 상태) 두 화면이 같은 칩 행을 쓰는 게 Figma에서 확인돼
// component-convention.md §1("두 번째 화면에서 실제로 재사용될 때 components/ui/로 옮긴다")대로 공용으로 뺐다.
// 가로 스크롤되는 단일 선택 행이 재사용 단위라 칩 하나가 아니라 행 전체를 컴포넌트로 둔다.
function FilterChipGroup({ options, value, onChange }: FilterChipGroupProps) {
return (
<div className="flex gap-1.5 overflow-x-auto">
{options.map((option) => {
const active = option.value === value;
return (
<button
className={
active
? "inline-flex h-8 shrink-0 items-center justify-center whitespace-nowrap rounded-[20px] border border-primary bg-primary-subtle px-3"
: "inline-flex h-8 shrink-0 items-center justify-center whitespace-nowrap rounded-[20px] border border-line-normal-neutral px-3"
}
key={option.value}
onClick={() => onChange(option.value)}
type="button"
>
<Typography
color={
active
? "semantic.primary.normal"
: "semantic.label.alternative"
}
variant="caption1"
weight={active ? "bold" : "medium"}
>
{option.label}
</Typography>
</button>
);
})}
</div>
);
}

export default FilterChipGroup;
45 changes: 27 additions & 18 deletions src/components/ui/ScreenHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ type ScreenHeaderProps =
variant?: "display";
title?: ScreenHeaderTitle;
trailing?: ReactNode;
toolbar?: ReactNode;
}
| {
variant: "normal";
Expand All @@ -71,8 +72,13 @@ type ScreenHeaderProps =
//
// search variant(타이틀 자리가 검색 필드로 바뀌는 패턴)는 이번 범위에서 뺐다 —
// docs/plans/unified-screen-header.md 참고. 화면이 실제로 생기면 그때 추가한다.
//
// toolbar는 Figma에서 타이틀 행 바로 아래에 붙는 "Tool" 영역(행사/신청내역 같은 세그먼트 토글)이다.
// 화면 본문에 두면 헤더 고정 영역 밖이라 스크롤 경계가 화면마다 달라져서, 헤더가 같이 들고 있는다.
// display variant에만 있다 — normal(모달형)에서 쓰는 화면이 아직 없다.
function ScreenHeader(props: ScreenHeaderProps) {
const { title, trailing } = props;
const toolbar = props.variant === "normal" ? undefined : props.toolbar;

if (props.variant === "normal") {
return (
Expand All @@ -93,25 +99,28 @@ function ScreenHeader(props: ScreenHeaderProps) {
}

return (
<div className="flex w-full items-center justify-between px-5 py-3">
<div className="flex min-w-0 items-center">
{title !== undefined &&
(isToggleTitle(title) ? (
<ScreenHeaderToggleTitle {...title} />
) : (
<Typography
as="h2"
color="semantic.label.strong"
variant="title3"
weight="bold"
>
{title}
</Typography>
))}
<div className="flex w-full flex-col">
<div className="flex w-full items-center justify-between px-5 py-3">
<div className="flex min-w-0 items-center">
{title !== undefined &&
(isToggleTitle(title) ? (
<ScreenHeaderToggleTitle {...title} />
) : (
<Typography
as="h2"
color="semantic.label.strong"
variant="title3"
weight="bold"
>
{title}
</Typography>
))}
</div>
{trailing !== undefined && (
<div className="flex shrink-0 items-center gap-4">{trailing}</div>
)}
</div>
{trailing !== undefined && (
<div className="flex shrink-0 items-center gap-4">{trailing}</div>
)}
{toolbar}
</div>
);
}
Expand Down
4 changes: 3 additions & 1 deletion src/components/ui/ScreenLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,9 @@ import { ScreenSheetPortalContext } from "@/components/ui/screenSheetPortalConte
const BOTTOM_NAV_PATHS: Record<BottomNavValue, string> = {
bililge: "/bililge",
board: "/board",
event: "/event",
// 행사 목록 라우트 경로. 용어 사전(terminology.md)의 코드 용어가 `events`이고
// 신청 폼 라우트도 `/events/:eventId/apply`라 여기도 복수형으로 맞춘다.
event: "/events",
home: "/",
};

Expand Down
52 changes: 15 additions & 37 deletions src/features/bililge/components/BililgeCategoryFilter.tsx
Original file line number Diff line number Diff line change
@@ -1,54 +1,32 @@
import { Typography } from "@wanteddev/wds";
import FilterChipGroup, {
type FilterChipOption,
} from "@/components/ui/FilterChipGroup";

const BILILGE_CATEGORIES = [
"전체",
"전자기기",
"생활잡화",
"상비약",
"위생용품",
const BILILGE_CATEGORIES: readonly FilterChipOption[] = [
{ label: "전체", value: "전체" },
{ label: "전자기기", value: "전자기기" },
{ label: "생활잡화", value: "생활잡화" },
{ label: "상비약", value: "상비약" },
{ label: "위생용품", value: "위생용품" },
];

interface BililgeCategoryFilterProps {
value: string;
onChange: (category: string) => void;
}

// Figma: 빌릴게 Filter Row (nodeId 1243:73337) — WDS Chip이 아니라 Stream 로컬 커스텀 칩.
// Figma: 빌릴게 Filter Row (nodeId 1243:73337) — 칩 자체는 행사 화면과 공유하는 FilterChipGroup을 쓴다.
// 카테고리·물품 매핑 API가 아직 없어 필터링 없이 선택 상태만 표시한다.
function BililgeCategoryFilter({
value,
onChange,
}: BililgeCategoryFilterProps) {
return (
<div className="flex gap-1.5 overflow-x-auto">
{BILILGE_CATEGORIES.map((category) => {
const active = category === value;
return (
<button
className={
active
? "inline-flex h-8 shrink-0 items-center justify-center whitespace-nowrap rounded-[20px] border border-primary bg-primary-subtle px-3"
: "inline-flex h-8 shrink-0 items-center justify-center whitespace-nowrap rounded-[20px] border border-line-normal-neutral px-3"
}
key={category}
onClick={() => onChange(category)}
type="button"
>
<Typography
color={
active
? "semantic.primary.normal"
: "semantic.label.alternative"
}
variant="caption1"
weight={active ? "bold" : "medium"}
>
{category}
</Typography>
</button>
);
})}
</div>
<FilterChipGroup
onChange={onChange}
options={BILILGE_CATEGORIES}
value={value}
/>
);
}

Expand Down
118 changes: 118 additions & 0 deletions src/features/events/EventsListScreen.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import {
Divider,
SegmentedControl,
SegmentedControlItem,
TopNavigationButton,
Typography,
} from "@wanteddev/wds";
import { IconBell, IconSearch } from "@wanteddev/wds-icon";
import { Fragment, useState } from "react";

import FilterChipGroup from "@/components/ui/FilterChipGroup";
import ScreenHeader from "@/components/ui/ScreenHeader";
import { useScreenHeader } from "@/components/ui/useScreenHeader";
import EventsCard from "@/features/events/components/EventsCard";
import {
EVENT_STATUS_FILTERS,
EVENTS,
} from "@/features/events/constants/events";

// Figma: 행사 (nodeId 1243:70854)
function EventsListScreen() {
const [tab, setTab] = useState("event");
const [statusFilter, setStatusFilter] = useState("all");

// 행사/신청내역 토글은 Figma에서 Top Navigation 인스턴스 안(타이틀 행 아래)에 있어서
// ScreenHeader의 toolbar 슬롯으로 넘긴다 — WDS TopNavigation이 원래 갖고 있는 슬롯이다.
useScreenHeader(
<ScreenHeader
title="행사"
toolbar={
// Figma Tool 프레임(56~88)은 높이 32에 아래 여백이 없다 — 세로 패딩을 주면 헤더가 그만큼 길어진다
<div className="px-5">
<SegmentedControl onValueChange={setTab} size="small" value={tab}>
<SegmentedControlItem value="event">행사</SegmentedControlItem>
<SegmentedControlItem value="application">
신청내역
</SegmentedControlItem>
</SegmentedControl>
</div>
}
trailing={
<>
<TopNavigationButton aria-label="검색" variant="icon">
<IconSearch />
</TopNavigationButton>
<TopNavigationButton aria-label="알림" variant="icon">
<IconBell />
</TopNavigationButton>
</>
}
/>,
);

// 빌릴게 카테고리 필터와 달리 모집 상태는 목데이터에 이미 들어있어서 실제로 걸러낼 수 있다.
const visibleEvents =
statusFilter === "all"
? EVENTS
: EVENTS.filter((event) => event.status === statusFilter);

return (
<>
{/* 필터는 고정하고 목록만 스크롤된다 */}
<div className="shrink-0 px-5 pt-4 pb-6">
<FilterChipGroup
onChange={setStatusFilter}
options={EVENT_STATUS_FILTERS}
value={statusFilter}
/>
</div>

<div className="scrollbar-hidden flex-1 overflow-y-auto">
{tab === "event" ? (
<div className="flex flex-col gap-6 pb-4">
{visibleEvents.map((event, index) => (
<Fragment key={event.id}>
{index > 0 && (
<div className="px-5">
<Divider color="semantic.line.normal.alternative" />
</div>
)}
<EventsCard
actionLabel={event.actionLabel}
eventDate={event.eventDate}
status={event.status}
statusLabel={event.statusLabel}
title={event.title}
/>
</Fragment>
))}
{visibleEvents.length === 0 && (
<div className="flex items-center justify-center py-20">
<Typography
color="semantic.label.alternative"
variant="label2"
weight="regular"
>
해당하는 행사가 없어요
</Typography>
</div>
)}
</div>
) : (
<div className="flex items-center justify-center py-20">
<Typography
color="semantic.label.alternative"
variant="label2"
weight="regular"
>
신청내역은 아직 준비 중이에요
</Typography>
</div>
)}
</div>
</>
);
}

export default EventsListScreen;
Loading