Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
20 changes: 20 additions & 0 deletions docs/conventions/wds-component-usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -584,3 +584,23 @@ A-1 본문의 `Zone Grid Row`(`2159:110823`)가 `h-px`라 `위쪽 칸/아래쪽
- **재사용(수정 없음)**: 행사 `EventsCard`, 공지 `NoticesCard`, 열린피드백 `FeedbacksCard`
- **빌릴게**: `BililgeItemCard`에 `bordered` prop만 추가했다(흰 배경 위에서 테두리 `border-line-solid-neutral`). 메인 목록은 회색 배경 위라 테두리가 없다
- **아카이빙**: Figma `Search Archiving Card`(`3013:127633`)는 Stream 로컬이고 이 화면 전용이라 `features/search/components/SearchArchivingCard.tsx`로 새로 만들었다

## 설정 화면 (설정 3013:116135 / 알림 설정 3013:116182 / 전화번호 변경 1799:87220)

### 새로 확정된 WDS 매핑

| Figma 인스턴스 | 코드 | 확정 근거 |
|---|---|---|
| `Switch/Switch` | `Switch`(`@wanteddev/wds`) | 메인 컴포넌트 Node ID `679:16080` + [공식 문서](https://montage.wanted.co.kr/docs/components/selection-and-input/switch/design). `size="small"`이 Figma와 같은 39×24(실측 일치) |
| `Textinput/Textfield` | `TextField`(`@wanteddev/wds`) | 메인 컴포넌트 Node ID `445:8591` + [공식 문서](https://montage.wanted.co.kr/docs/components/selection-and-input/text-field/design). radius 12px·padding 12px 실측 일치 |
| `Icon/Normal/Setting` | `IconSetting`(`@wanteddev/wds-icon`) | 메인 컴포넌트 Node ID `492:5735`("설정으로 이동할 때 사용합니다") |

**`TextField`는 입력 박스만 그린다** — Figma `Textinput/Textfield`는 라벨(Heading) + 입력칸 + 안내 문구를 한 컴포넌트로 묶고 있지만, 코드 쪽 `TextField`에는 label·helper 슬롯이 없다. 그래서 "새 전화번호" 라벨과 "행사 신청 시 사용돼요." 안내는 Figma 구조대로 형제 요소로 따로 그린다. WDS `Label`은 required `*`만 더해주는 `Typography` 래퍼라 쓸 이유가 없다(행사 신청 폼 절의 선례와 같은 판단).

### 설정 행의 `Arrow`는 WDS가 아니다

설정 화면 모든 행 끝의 화살표(`445:12456` "Arrow", 8×12)는 `search_design_system`으로 WDS를 뒤져도 같은 이름이 없다(WDS는 `Icon/Normal/Arrow *`·`Icon/Normal/Chevron *` 네이밍). 133번째 줄 "제외됨" 목록의 `Icon/Arrow`와 같은 Stream 전용 아이콘이라 SVG를 받아 `src/assets/icons/settings/arrow-right.svg`로 커밋했다. 색(`#C2C4C8` = `Atomic/Cool Neutral/90`)이 에셋에 구워져 있어 토큰은 새로 추가하지 않았다.

### 알림 설정의 섹션 구분선은 8px 버전

"`Divider(new)`의 8px 버전은 1px 구분선과 다른 별개 패턴" 절과 같은 케이스다 — `<div className="h-2 w-full bg-background-alternative" />`로 직접 그린다.
49 changes: 49 additions & 0 deletions src/app/router.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,11 @@ import LockersSectionSelectScreen from "@/features/lockers/LockersSectionSelectS
import NoticesDetailScreen from "@/features/notices/NoticesDetailScreen";
import NoticesListScreen from "@/features/notices/NoticesListScreen";
import SearchScreen from "@/features/search/SearchScreen";
import NotificationSettingsScreen from "@/features/settings/NotificationSettingsScreen";
import PhoneChangeScreen from "@/features/settings/PhoneChangeScreen";
import SettingsScreen from "@/features/settings/SettingsScreen";
import WithdrawalCompleteScreen from "@/features/settings/WithdrawalCompleteScreen";
import WithdrawalScreen from "@/features/settings/WithdrawalScreen";

// 앱의 모든 라우트는 이 객체 배열 한곳에서 정의한다 — 새 화면은 여기에 라우트를 추가한다.
// satisfies로 선언 시점에 RouteObject 형태를 검사한다.
Expand Down Expand Up @@ -117,6 +122,50 @@ const routes = [
} satisfies ScreenRouteHandle,
path: "/notices/:noticeId",
},
{
element: <SettingsScreen />,
// 흰 카드가 회색 배경 위에 쌓이는 화면이라 background는 기본값(alternative)을 쓴다.
// 설정은 뒤로가기로만 빠져나가는 흐름이라 Bottom Nav를 안 보여준다(Figma 3013:116135).
handle: { hasBottomNav: false } satisfies ScreenRouteHandle,
path: "/settings",
},
{
element: <NotificationSettingsScreen />,
// 행이 배경까지 흰 면이고 섹션 구분만 8px 회색 띠로 하는 화면이다(Figma 3013:116182)
handle: {
background: "normal",
hasBottomNav: false,
} satisfies ScreenRouteHandle,
path: "/settings/notifications",
},
{
element: <PhoneChangeScreen />,
// 입력칸 하나 + 하단 고정 버튼(Action Area)이 있는 흰 배경 화면(Figma 1799:87220)
handle: {
background: "normal",
hasBottomNav: false,
} satisfies ScreenRouteHandle,
path: "/settings/phone",
},
{
element: <WithdrawalScreen />,
// 안내 카드가 회색이라 화면은 흰 면이어야 한다(Figma 3524:152473).
// 하단 고정 버튼(Action Area)이 있어 Bottom Nav는 없다.
handle: {
background: "normal",
hasBottomNav: false,
} satisfies ScreenRouteHandle,
path: "/settings/withdraw",
},
{
element: <WithdrawalCompleteScreen />,
// 완료 화면 — 하단 탭 없이 흰 배경 전체 화면이다(Figma 3524:152505)
handle: {
background: "normal",
hasBottomNav: false,
} satisfies ScreenRouteHandle,
path: "/settings/withdraw/complete",
},
{
element: <LockersApplyScreen />,
// 시트만 있는 화면이라 뒤에 Bottom Nav가 비쳐도 Figma(홈 위에 뜨는 시트)와 같다
Expand Down
5 changes: 5 additions & 0 deletions src/assets/icons/settings/arrow-right.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
3 changes: 3 additions & 0 deletions src/components/ui/ConfirmModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -165,8 +165,11 @@ function ConfirmModal({
{title}
</Typography>
{description && (
// Figma 모달 설명은 줄바꿈 위치까지 디자인에 포함돼 있어서(예: 탈퇴 확인 모달
// 3524:152535) `\n`을 그대로 살린다. 기존 사용처는 모두 한 줄 문구라 영향이 없다.
<Typography
as="p"
className="whitespace-pre-line"
color="semantic.label.alternative"
variant="label1-reading"
weight="regular"
Expand Down
10 changes: 9 additions & 1 deletion src/features/home/HomeScreen.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { TopNavigationButton } from "@wanteddev/wds";
import { IconBell, IconSearch } from "@wanteddev/wds-icon";
import { IconBell, IconSearch, IconSetting } from "@wanteddev/wds-icon";
import { Link, useNavigate } from "react-router-dom";
import chatbotIcon from "@/assets/icons/chat/bot.svg";
import ScreenHeader from "@/components/ui/ScreenHeader";
Expand All @@ -18,6 +18,14 @@ function HomeScreen() {
title="STREAM"
trailing={
<>
{/* Figma 홈 Top Navigation(3147:146294)의 아이콘은 설정·검색·알림 3개 순서다 */}
<TopNavigationButton
aria-label="설정"
onClick={() => navigate("/settings")}
variant="icon"
>
<IconSetting />
</TopNavigationButton>
<TopNavigationButton
aria-label="검색"
onClick={() => navigate(getSearchPath())}
Expand Down
95 changes: 95 additions & 0 deletions src/features/settings/NotificationSettingsScreen.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import { TopNavigationButton } from "@wanteddev/wds";
import { IconChevronLeft } from "@wanteddev/wds-icon";
import { useState } from "react";
import { useNavigate } from "react-router-dom";

import ScreenHeader from "@/components/ui/ScreenHeader";
import { useScreenHeader } from "@/components/ui/useScreenHeader";
import NotificationToggleRow from "@/features/settings/components/NotificationToggleRow";
import {
NOTIFICATION_CATEGORIES,
type NotificationCategoryId,
} from "@/features/settings/constants/settings";

// Figma 기본값: 전체 알림 off, 카테고리는 빌릴게만 on (3013:116191, 3013:116199)
const DEFAULT_CATEGORY_STATE: Record<NotificationCategoryId, boolean> = {
bililge: true,
board: false,
events: false,
lockers: false,
};

// Figma: 알림 설정 (nodeId 3013:116182) — 전체 알림 행, 8px 두꺼운 구분선, 카테고리 4개.
// 구분선은 Figma `Divider(new)`의 8px 버전이라 1px 헤어라인(WDS Divider)과 다른 패턴이다
// (wds-component-usage.md "Divider(new)의 8px 버전은 1px 구분선과 다른 별개 패턴" 참고).
//
// 전체 알림은 카테고리를 한 번에 켜고 끄는 마스터 스위치다. 그래서 자체 state를 따로 두지 않고
// "카테고리가 전부 켜져 있는가"로 계산한다 — 이러면 카테고리를 하나라도 끄는 순간 전체 알림도
// 같이 꺼져서 두 값이 어긋날 일이 없다. Figma 기본 상태(빌릴게만 on → 전체 알림 off)도 이
// 계산 결과와 그대로 맞는다. on/off 저장은 API 연동 전이라 화면 state로만 둔다.
function NotificationSettingsScreen() {
const navigate = useNavigate();
const [categoryEnabled, setCategoryEnabled] = useState(
DEFAULT_CATEGORY_STATE,
);
const allEnabled = NOTIFICATION_CATEGORIES.every(
(category) => categoryEnabled[category.id],
);

const handleGlobalChange = (checked: boolean) => {
setCategoryEnabled((prev) => {
const next = { ...prev };
for (const category of NOTIFICATION_CATEGORIES) {
next[category.id] = checked;
}
return next;
});
};

useScreenHeader(
<ScreenHeader
leading={
<TopNavigationButton
aria-label="뒤로가기"
onClick={() => navigate(-1)}
variant="icon"
>
<IconChevronLeft />
</TopNavigationButton>
}
title="알림 설정"
variant="normal"
/>,
);

return (
<div className="scrollbar-hidden flex h-full flex-col gap-6 overflow-y-auto pt-2">
<NotificationToggleRow
checked={allEnabled}
onCheckedChange={handleGlobalChange}
title="전체 알림"
/>

<div className="h-2 w-full shrink-0 bg-background-alternative" />

<div className="flex flex-col gap-5">
{NOTIFICATION_CATEGORIES.map((category) => (
<NotificationToggleRow
checked={categoryEnabled[category.id]}
description={category.description}
key={category.id}
onCheckedChange={(checked) =>
setCategoryEnabled((prev) => ({
...prev,
[category.id]: checked,
}))
}
title={category.title}
/>
))}
</div>
</div>
);
}

export default NotificationSettingsScreen;
128 changes: 128 additions & 0 deletions src/features/settings/PhoneChangeScreen.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
import {
ActionArea,
ActionAreaButton,
TextField,
TopNavigationButton,
Typography,
} from "@wanteddev/wds";
import { IconChevronLeft } from "@wanteddev/wds-icon";
import { useState } from "react";
import { useNavigate } from "react-router-dom";

import ScreenHeader from "@/components/ui/ScreenHeader";
import ScreenToast from "@/components/ui/ScreenToast";
import { useScreenHeader } from "@/components/ui/useScreenHeader";

// Figma: 전화번호 변경 (nodeId 1799:87220), 입력 중 (1799:87227), 완료 토스트 (1917:93781).
// 입력칸은 Figma `Textinput/Textfield`(445:8591, WDS 공식 문서 링크 확인)라 WDS `TextField`를
// 그대로 쓴다. 다만 WDS `TextField`는 입력 박스만 그려서, 위 라벨("새 전화번호")과 아래 안내
// 문구는 Figma 구조대로 형제 요소로 따로 그린다 — WDS `Label`은 required `*` 처리만 더해주는
// Typography 래퍼라 여기선 쓸 이유가 없다(wds-component-usage.md의 행사 신청 폼 선례와 같다).
//
// "변경하기" 버튼은 번호를 형식에 맞게 다 입력했을 때만 활성화된다(Figma 비활성 #F4F4F5 /
// 활성 #0066FF). 높이 56px은 ActionAreaButton 기본(48px)과 달라 sx로 세로 padding만 보정한다.
// 실제 변경 API가 없어서 누르면 Figma처럼 완료 토스트만 띄운다.
//
// Figma 입력 중 상태(1799:87230)가 "010-1234-5"처럼 하이픈이 끼워진 모습이라 입력하는 대로
// 하이픈을 넣어준다. 유효 기준은 placeholder와 같은 010 + 8자리다.
const PHONE_PATTERN = /^010-\d{4}-\d{4}$/;

function formatPhone(value: string): string {
const digits = value.replace(/\D/g, "").slice(0, 11);
if (digits.length <= 3) {
return digits;
}
if (digits.length <= 7) {
return `${digits.slice(0, 3)}-${digits.slice(3)}`;
}
return `${digits.slice(0, 3)}-${digits.slice(3, 7)}-${digits.slice(7)}`;
}

function PhoneChangeScreen() {
const navigate = useNavigate();
const [phone, setPhone] = useState("");
const [toastOpen, setToastOpen] = useState(false);
// 토스트가 떠 있는 중에 다시 저장해도 스크린리더가 재안내하도록 매번 새로 마운트한다
// (EventsApplicationScreen·BililgeReturnSection과 같은 패턴).
const [toastToken, setToastToken] = useState(0);

useScreenHeader(
<ScreenHeader
leading={
<TopNavigationButton
aria-label="뒤로가기"
onClick={() => navigate(-1)}
variant="icon"
>
<IconChevronLeft />
</TopNavigationButton>
}
title="전화번호 변경"
variant="normal"
/>,
);

const handleSave = () => {
setToastOpen(true);
setToastToken((token) => token + 1);
};

return (
<div className="flex h-full flex-col">
<div className="flex flex-1 flex-col gap-2 px-5 pt-2">
<Typography
as="label"
color="semantic.label.neutral"
htmlFor="phone"
variant="label1"
weight="bold"
>
새 전화번호
</Typography>
<TextField
id="phone"
inputMode="tel"
maxLength={13}
onChange={(event) => setPhone(formatPhone(event.target.value))}
placeholder="010-1234-5678"
type="tel"
value={phone}
/>
<Typography
as="p"
color="semantic.label.alternative"
variant="caption1"
weight="regular"
>
행사 신청 시 사용돼요.
</Typography>
</div>

<div className="shrink-0">
<ActionArea>
{/* Figma Main Action은 56px인데 ActionAreaButton(size="large")은 48px이라 세로 padding만 보정 */}
<ActionAreaButton
disabled={!PHONE_PATTERN.test(phone)}
onClick={handleSave}
sx={{ paddingBlock: "16px" }}
>
변경하기
</ActionAreaButton>
</ActionArea>
{/* Figma Action Area(110px)의 버튼 아래 34px 중 WDS가 주는 padding 20px을 뺀 14px을 더한다
(EventsApplicationScreen과 같은 보정) */}
<div className="h-safe-bottom-extra bg-background-elevated-normal sm:h-[14px]" />
</div>

<ScreenToast
key={toastToken}
message="전화번호를 변경했어요."
onOpenChange={setToastOpen}
open={toastOpen}
variant="positive"
/>
</div>
);
}

export default PhoneChangeScreen;
Loading