diff --git a/docs/conventions/wds-component-usage.md b/docs/conventions/wds-component-usage.md index 4beaefb..e36e639 100644 --- a/docs/conventions/wds-component-usage.md +++ b/docs/conventions/wds-component-usage.md @@ -649,3 +649,31 @@ Figma는 헤더 배경이 없어서 배너 그림자(`Shadow/Spread/Small`, 60px ### 설정 화면의 "전체 알림"은 상태를 따로 두지 않는다 Figma 초기 상태가 전체 꺼짐 + 빌릴게만 켜짐이라 "전체"는 독립된 값이 아니라 카테고리가 모두 켜졌는지로 파생한다. 누르면 네 카테고리를 같은 값으로 맞추고, 하나라도 끄면 전체가 꺼진다. 값은 화면 state뿐이라 새로고침하면 초기값으로 돌아간다(서버 API 연동 때 교체). + +## 설정 화면 (설정 `3524:152322` / 전화번호 변경 `3524:152450` / 회원 탈퇴 `3524:152473`) + +코드는 `src/features/settings/` 참고. + +### 알림 설정 화면은 설정이 아니라 알림 기능이 갖는다 + +Figma는 같은 알림 설정 화면을 **설정 섹션(`3524:152367`)과 알림 섹션(`3628:110837`) 두 군데에 복제**해 뒀다(노드 구조·좌표·문구가 완전히 같다). 디자인이 둘이라고 화면을 둘 만들면 안 된다 — 코드는 `features/notifications/NotificationsSettingsScreen`(`/notifications/settings`) 한 벌만 두고, 설정 화면의 "알림 설정" 행과 알림 목록 헤더의 설정 버튼이 **같은 경로로** 들어간다. 카테고리 문구·초기값도 `features/notifications/constants/notifications.ts`가 단일 출처다. + +### 새로 확정된 WDS 매핑 + +| Figma 인스턴스 | 코드 | 확정 근거 | +|---|---|---| +| `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 실측 일치 | + +`Switch/Switch`·`Icon/Normal/Setting`·`Divider(new)` 8px은 위 "알림" 절에서 이미 확정한 매핑을 그대로 쓴다. + +**`TextField`는 입력 박스만 그린다** — Figma `Textinput/Textfield`는 라벨(Heading) + 입력칸 + 안내 문구를 한 컴포넌트로 묶고 있지만, 코드 쪽 `TextField`에는 label·helper 슬롯이 없다. 그래서 "새 전화번호" 라벨과 "행사 신청 시 사용돼요." 안내는 Figma 구조대로 형제 요소로 따로 그린다. WDS `Label`은 required `*`만 더해주는 `Typography` 래퍼라 쓸 이유가 없다(행사 신청 폼 절의 선례와 같은 판단). 값이 있을 때 뜨는 지움(X) 버튼은 `TextField`가 기본으로 그려서 Figma 입력 중 상태(`3524:152465`)와 그대로 맞는다. + +### 설정 행의 `Arrow`는 WDS가 아니다 + +설정 화면 모든 행 끝의 화살표(`445:12456` "Arrow")는 `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`)이 에셋에 구워져 있어 토큰은 새로 추가하지 않았다. + +**에셋은 12×12인데 슬롯은 8×12다.** Figma도 12×12 뷰박스를 `width: 8px; height: 12px` 자리에 `preserveAspectRatio="none"`으로 넣어 가로를 눌러 쓴다. 내려받은 SVG를 그대로 `className="h-3 w-2"`로 쓰는 게 Figma와 같은 결과다 — 8×12로 다시 내보내거나 종횡비를 맞추려 들면 오히려 어긋난다. + +### Figma 노드 ID가 통째로 갈린 적이 있다 + +설정 섹션은 한 번 통째로 다시 만들어졌다. 옛 ID(`1799:85920` 섹션, 설정 `3013:116135`, 알림 설정 `3013:116182`, 전화번호 변경 `1799:87220`, 로그아웃 모달 `1799:87183`)는 **파일에서 삭제되어 조회되지 않는다.** 탈퇴 계열(`3524:152473`·`3524:152505`·`3524:152517`)만 그대로다. 주석의 nodeId가 `get_design_context`에서 "not found"로 나오면 먼저 섹션이 다시 만들어졌는지 확인한다. diff --git a/src/app/router.tsx b/src/app/router.tsx index 1604574..90021e4 100644 --- a/src/app/router.tsx +++ b/src/app/router.tsx @@ -30,6 +30,10 @@ import NoticesListScreen from "@/features/notices/NoticesListScreen"; import NotificationsScreen from "@/features/notifications/NotificationsScreen"; import NotificationsSettingsScreen from "@/features/notifications/NotificationsSettingsScreen"; import SearchScreen from "@/features/search/SearchScreen"; +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 형태를 검사한다. @@ -123,6 +127,43 @@ const routes = [ } satisfies ScreenRouteHandle, path: "/notices/:noticeId", }, + { + element: , + // 흰 카드가 회색 배경 위에 쌓이는 화면이라 background는 기본값(alternative)을 쓴다. + // 설정은 뒤로가기로만 빠져나가는 흐름이라 Bottom Nav를 안 보여준다(Figma 3524:152322). + // 설정의 "알림 설정" 행은 알림 화면이 이미 가진 /notifications/settings로 보낸다 — + // Figma는 설정·알림 두 섹션에 같은 화면을 복제해 뒀지만 코드에서는 한 벌만 둔다. + handle: { hasBottomNav: false } satisfies ScreenRouteHandle, + path: "/settings", + }, + { + element: , + // 입력칸 하나 + 하단 고정 버튼(Action Area)이 있는 흰 배경 화면(Figma 3524:152450) + handle: { + background: "normal", + hasBottomNav: false, + } satisfies ScreenRouteHandle, + path: "/settings/phone", + }, + { + element: , + // 안내 카드가 회색이라 화면은 흰 면이어야 한다(Figma 3524:152473). + // 하단 고정 버튼(Action Area)이 있어 Bottom Nav는 없다. + handle: { + background: "normal", + hasBottomNav: false, + } satisfies ScreenRouteHandle, + path: "/settings/withdrawal", + }, + { + element: , + // 완료 화면 — 하단 탭 없이 흰 배경 전체 화면이다(Figma 3524:152505) + handle: { + background: "normal", + hasBottomNav: false, + } satisfies ScreenRouteHandle, + path: "/settings/withdrawal/complete", + }, { element: , // 시트만 있는 화면이라 뒤에 Bottom Nav가 비쳐도 Figma(홈 위에 뜨는 시트)와 같다 diff --git a/src/assets/icons/settings/arrow-right.svg b/src/assets/icons/settings/arrow-right.svg new file mode 100644 index 0000000..be36674 --- /dev/null +++ b/src/assets/icons/settings/arrow-right.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/components/ui/ConfirmModal.tsx b/src/components/ui/ConfirmModal.tsx index 2f9ae8a..baeb144 100644 --- a/src/components/ui/ConfirmModal.tsx +++ b/src/components/ui/ConfirmModal.tsx @@ -165,8 +165,11 @@ function ConfirmModal({ {title} {description && ( + // Figma 모달 설명은 줄바꿈 위치까지 디자인에 포함돼 있어서(예: 탈퇴 확인 모달 + // 3524:152535) `\n`을 그대로 살린다. 기존 사용처는 모두 한 줄 문구라 영향이 없다. { + return mockupApi("users/me", () => MY_ACCOUNT); +} + +// 앱 버전은 계정과 다른 요청이라 key를 나눈다 — 설정 화면이 둘을 한 카드에 섞어 쓰지 않는다. +export function fetchAppVersion(): Promise { + return mockupApi("users/app-version", () => APP_VERSION); +} diff --git a/src/entities/users/usersMock.ts b/src/entities/users/usersMock.ts new file mode 100644 index 0000000..50a3a8c --- /dev/null +++ b/src/entities/users/usersMock.ts @@ -0,0 +1,13 @@ +import type { AppVersion, MyAccount } from "@/entities/users/types"; + +// Figma 설정 화면(3524:152322)에 적힌 값 그대로. 실 API가 붙으면 이 파일은 사라진다. +export const MY_ACCOUNT: MyAccount = { + email: "pog454@kookmin.ac.kr", + name: "이예담", + phone: "010-1234-5678", +}; + +export const APP_VERSION: AppVersion = { + isLatest: true, + version: "26.0.1", +}; diff --git a/src/features/home/HomeScreen.tsx b/src/features/home/HomeScreen.tsx index 8b81aa9..ca144ab 100644 --- a/src/features/home/HomeScreen.tsx +++ b/src/features/home/HomeScreen.tsx @@ -17,6 +17,7 @@ import { } from "@/features/home/constants/homeMock"; import { NOTIFICATIONS_PATH } from "@/features/notifications/constants/notifications"; import { getSearchPath } from "@/features/search/constants/search"; +import { SETTINGS_PATHS } from "@/features/settings/constants/settings"; // Figma: 홈 2건 이하 (nodeId 3147:146240), 3건 이상 (3147:146297), empty (3147:146366) // 챗봇 FAB(Figma nodeId 2443:178457 등, 홈 화면 variant마다 우하단에 고정) — @@ -49,7 +50,13 @@ function HomeScreen() { } trailing={ <> - + + navigate(SETTINGS_PATHS.root, { viewTransition: true }) + } + variant="icon" + > navigate(-1)} + variant="icon" + > + + + } + title="전화번호 변경" + variant="normal" + />, + ); + + const handleSave = () => { + setToastOpen(true); + setToastToken((token) => token + 1); + }; + + return ( +
+
+ + 새 전화번호 + + setPhone(formatPhoneNumber(event.target.value))} + placeholder="010-1234-5678" + type="tel" + value={phone} + /> + + 행사 신청 시 사용돼요. + +
+ +
+ + {/* Figma Main Action은 56px인데 ActionAreaButton(size="large")은 48px이라 세로 padding만 보정 */} + + 변경하기 + + + {/* Figma Action Area(110px)의 버튼 아래 34px 중 WDS가 주는 padding 20px을 뺀 14px을 더한다 + (EventsApplicationScreen과 같은 보정) */} +
+
+ + +
+ ); +} + +export default PhoneChangeScreen; diff --git a/src/features/settings/SettingsScreen.tsx b/src/features/settings/SettingsScreen.tsx new file mode 100644 index 0000000..d2e5113 --- /dev/null +++ b/src/features/settings/SettingsScreen.tsx @@ -0,0 +1,141 @@ +import { TopNavigationButton, Typography } from "@wanteddev/wds"; +import { IconChevronLeft } from "@wanteddev/wds-icon"; +import { Suspense, useState } from "react"; +import { useNavigate } from "react-router-dom"; + +import ConfirmModal from "@/components/ui/ConfirmModal"; +import ScreenHeader from "@/components/ui/ScreenHeader"; +import { useScreenHeader } from "@/components/ui/useScreenHeader"; +import { NOTIFICATIONS_SETTINGS_PATH } from "@/features/notifications/constants/notifications"; +import SettingsAccountRows from "@/features/settings/components/SettingsAccountRows"; +import SettingsAccountRowsSkeleton from "@/features/settings/components/SettingsAccountRowsSkeleton"; +import SettingsAppVersionRow from "@/features/settings/components/SettingsAppVersionRow"; +import SettingsAppVersionRowSkeleton from "@/features/settings/components/SettingsAppVersionRowSkeleton"; +import SettingsRow from "@/features/settings/components/SettingsRow"; +import SettingsSection from "@/features/settings/components/SettingsSection"; +import { + SETTINGS_EXTERNAL_LINKS, + SETTINGS_PATHS, +} from "@/features/settings/constants/settings"; +import { useAccountExit } from "@/features/settings/hooks/useAccountExit"; +import { openExternalLink } from "@/lib/openExternalLink"; + +// Figma: 설정 (nodeId 3524:152322) — 계정 정보 / 알림 설정 / 서비스 정보 / 로그아웃 네 장의 흰 +// 카드가 회색 배경 위에 8px 간격으로 쌓이고, 맨 아래에 Copyright가 깔린다. +// +// 의견 보내기·개인정보 처리방침은 화면을 따로 만들지 않고 구글폼으로 연결한다. 폼 주소가 아직 +// 없어서(SETTINGS_EXTERNAL_LINKS가 빈 문자열) 그동안은 두 행을 비활성으로 그린다 — 누를 수 +// 있어 보이는데 반응이 없는 상태를 피한다. 주소를 상수에 채우면 저절로 활성으로 돌아온다. +// Figma에는 이 비활성 상태가 없다(디자인에는 주소가 있다고 보고 그려져 있다). +// 로그아웃은 확인 모달(3524:152426)을 공용 ConfirmModal(tone="negative")로 붙였고, +// 회원 탈퇴는 별도 화면(3524:152473)으로 이동한다. +// +// "알림 설정"은 알림 화면이 이미 가진 화면으로 보낸다 — Figma가 설정 섹션(3524:152367)과 알림 +// 섹션(3628:110837)에 같은 화면을 복제해 뒀을 뿐, 진입점만 둘이고 화면은 하나다. +function SettingsScreen() { + const navigate = useNavigate(); + const exitAccount = useAccountExit(); + const [logoutOpen, setLogoutOpen] = useState(false); + + // 설정 아래로 화면을 쌓는 이동이라 슬라이드를 켠다(coding-style.md의 화면 전환 규칙). + const pushScreen = (to: string) => navigate(to, { viewTransition: true }); + + useScreenHeader( + navigate(-1)} + variant="icon" + > + + + } + title="설정" + variant="normal" + />, + ); + + return ( +
+
+ + }> + pushScreen(SETTINGS_PATHS.phoneChange)} + /> + + + + + pushScreen(NOTIFICATIONS_SETTINGS_PATH)} + /> + + + + + openExternalLink(SETTINGS_EXTERNAL_LINKS.feedbackForm) + } + /> + + openExternalLink(SETTINGS_EXTERNAL_LINKS.privacyPolicy) + } + /> + }> + + + + + + setLogoutOpen(true)} + tone="alternative" + /> + pushScreen(SETTINGS_PATHS.withdrawal)} + tone="negative" + /> + +
+ + {/* Copyright는 카드 묶음 아래 남는 공간 맨 끝에 붙는다(Figma 3524:152366) */} + + © 2026 stream + + + setLogoutOpen(false)} + // 로그아웃하면 돌아올 화면이 아니라 설정을 히스토리에서 치우고 나간다. + // 실제 세션 정리는 인증이 붙을 때 여기에 함께 붙인다(ACCOUNT_EXIT_PATH 주석 참고). + onConfirm={() => { + setLogoutOpen(false); + exitAccount(); + }} + open={logoutOpen} + title="정말 로그아웃할까요?" + tone="negative" + /> +
+ ); +} + +export default SettingsScreen; diff --git a/src/features/settings/WithdrawalCompleteScreen.tsx b/src/features/settings/WithdrawalCompleteScreen.tsx new file mode 100644 index 0000000..db04f9b --- /dev/null +++ b/src/features/settings/WithdrawalCompleteScreen.tsx @@ -0,0 +1,38 @@ +import { Navigate, useLocation } from "react-router-dom"; + +import CompleteCheck from "@/components/ui/CompleteCheck"; +import ResultScreen from "@/components/ui/ResultScreen"; +import { useAccountExit } from "@/features/settings/hooks/useAccountExit"; +import type { WithdrawalCompleteState } from "@/features/settings/types"; + +// Figma: 탈퇴 완료 페이지 (nodeId 3524:152505) +// 신청 결과 화면과 같은 뼈대라 ResultScreen을 쓴다. 버튼이 "확인" 하나뿐이라 +// secondaryAction을 주지 않고, 체크 모션이 붙는 완료 화면이라 간격은 8px이다. +// 나갈 곳은 로그아웃과 같은 자리라 useAccountExit를 함께 쓴다. +function WithdrawalCompleteScreen() { + const exitAccount = useAccountExit(); + const state = useLocation().state as WithdrawalCompleteState | null; + + // 탈퇴 직후에만 오는 화면이라, 주소로 바로 들어오면 홈으로 보낸다(사물함 신청 완료와 같은 처리). + // + // 다만 이건 **화면을 가리는 것뿐이고 탈퇴 여부를 확인하는 게 아니다** — navigate state는 + // 클라이언트가 만드는 값이라 마음먹으면 꾸밀 수 있다. 실제 탈퇴 API가 붙으면 이 화면이 + // 보여 주는 내용 자체는 민감하지 않지만, 뒤이어 붙을 "탈퇴 처리" 호출은 반드시 서버가 + // 세션으로 본인 확인을 해야 한다. 백엔드 연동 때 같이 확인할 것. + if (!state?.withdrawn) { + return ; + } + + return ( + } + illustrationGap={8} + onClose={exitAccount} + primaryAction={{ label: "확인", onClick: exitAccount }} + title="회원 탈퇴가 완료되었어요" + /> + ); +} + +export default WithdrawalCompleteScreen; diff --git a/src/features/settings/WithdrawalScreen.tsx b/src/features/settings/WithdrawalScreen.tsx new file mode 100644 index 0000000..e77dfb0 --- /dev/null +++ b/src/features/settings/WithdrawalScreen.tsx @@ -0,0 +1,145 @@ +import { + ActionArea, + ActionAreaButton, + Checkbox, + TopNavigationButton, + Typography, +} from "@wanteddev/wds"; +import { IconChevronLeft } from "@wanteddev/wds-icon"; +import { useId, useState } from "react"; +import { useNavigate } from "react-router-dom"; + +import ConfirmModal from "@/components/ui/ConfirmModal"; +import ScreenHeader from "@/components/ui/ScreenHeader"; +import { useScreenHeader } from "@/components/ui/useScreenHeader"; +import WithdrawalNoticeCard from "@/features/settings/components/WithdrawalNoticeCard"; +import { + SETTINGS_PATHS, + WITHDRAWAL_NOTICES, +} from "@/features/settings/constants/settings"; +import type { WithdrawalCompleteState } from "@/features/settings/types"; + +// Figma: 탈퇴 확인 페이지 (nodeId 3524:152473), 체크 상태 (3524:152489), 확인 모달 (3524:152517). +// 안내 문구 4장은 같은 컴포넌트의 인스턴스라 WithdrawalNoticeCard + WITHDRAWAL_NOTICES로 분리했다. +// +// 동의 체크박스를 켜야 "탈퇴하기"가 활성화되고(Figma 비활성 Interaction/Disable #F4F4F5 → +// 활성 Status/Negative #FF4242), 누르면 확인 모달이 뜬다. WDS Button은 color가 +// primary/assistive뿐이라 빨간 버튼은 ConfirmModal과 같은 방식으로 sx로 배경을 덮는다. +// +// 모달에서 탈퇴를 확정하면 탈퇴 완료 페이지(3524:152505)로 넘어간다. 실제 탈퇴 API 호출은 +// 연동할 때 onConfirm에 붙인다. +function WithdrawalScreen() { + const navigate = useNavigate(); + const consentId = useId(); + const [agreed, setAgreed] = useState(false); + const [confirmOpen, setConfirmOpen] = useState(false); + + useScreenHeader( + navigate(-1)} + variant="icon" + > + + + } + variant="normal" + />, + ); + + return ( +
+ {/* Figma Withdrawal Content(3524:152476): 헤더 아래 12px, 제목과 목록 사이 16px */} +
+ + 탈퇴하기 전에 꼭 +
+ 확인해 주세요 +
+ +
    + {WITHDRAWAL_NOTICES.map((notice) => ( + + ))} +
+
+ +
+
+ + +
+ + + {/* Figma Main Action은 56px인데 ActionAreaButton(size="large")은 48px이라 세로 padding을 + 보정하고, WDS Button엔 negative 색이 없어 배경만 status/negative로 덮는다. */} + setConfirmOpen(true)} + sx={ + agreed + ? { + backgroundColor: "var(--color-status-negative)", + paddingBlock: "16px", + } + : { paddingBlock: "16px" } + } + > + 탈퇴하기 + + + {/* Figma Action Area(110px)의 버튼 아래 34px 중 WDS가 주는 padding 20px을 뺀 14px */} +
+
+ + setConfirmOpen(false)} + // 탈퇴를 확정하면 되돌아올 화면이 아니라 완료 화면으로 치환한다. + // 모달을 먼저 닫는 건 로그아웃 확인 모달과 같은 순서다. + onConfirm={() => { + setConfirmOpen(false); + navigate(SETTINGS_PATHS.withdrawalComplete, { + replace: true, + state: { withdrawn: true } satisfies WithdrawalCompleteState, + viewTransition: true, + }); + }} + open={confirmOpen} + title="정말 탈퇴하시겠어요?" + tone="negative" + /> +
+ ); +} + +export default WithdrawalScreen; diff --git a/src/features/settings/components/SettingsAccountRows.tsx b/src/features/settings/components/SettingsAccountRows.tsx new file mode 100644 index 0000000..1dba28b --- /dev/null +++ b/src/features/settings/components/SettingsAccountRows.tsx @@ -0,0 +1,30 @@ +import { use } from "react"; + +import { fetchMyAccount } from "@/entities/users/usersApi"; +import SettingsRow from "@/features/settings/components/SettingsRow"; + +interface SettingsAccountRowsProps { + onChangePhone: () => void; +} + +// 계정 정보 카드(Figma 3524:152331)의 세 행 — 서버에서 받는 값이라 카드 안쪽만 데이터 컴포넌트로 +// 뺀다. 받는 동안은 SettingsScreen의 Suspense가 SettingsAccountRowsSkeleton을 보여준다. +// 카드 껍데기(SettingsSection)와 "계정 정보" 라벨은 데이터와 무관해서 화면이 바로 그린다. +function SettingsAccountRows({ onChangePhone }: SettingsAccountRowsProps) { + const account = use(fetchMyAccount()); + + return ( + <> + + + + + ); +} + +export default SettingsAccountRows; diff --git a/src/features/settings/components/SettingsAccountRowsSkeleton.tsx b/src/features/settings/components/SettingsAccountRowsSkeleton.tsx new file mode 100644 index 0000000..9b9458c --- /dev/null +++ b/src/features/settings/components/SettingsAccountRowsSkeleton.tsx @@ -0,0 +1,40 @@ +import { Skeleton } from "@wanteddev/wds"; + +import { useSkeletonAnimation } from "@/components/ui/ScreenSkeleton"; + +// 계정 정보 세 행의 자리를 그대로 따른다 — 행 높이 20px, 행 사이 20px(SettingsSection 기본 간격). +// 라벨은 폭이 고정이라 같은 자리를 잡아 두고, 값은 길이가 제각각이라 넉넉한 비율로 둔다. +const ROWS = [ + { key: "name", labelWidth: "28px", valueWidth: "48px" }, + { key: "email", labelWidth: "42px", valueWidth: "140px" }, + { key: "phone", labelWidth: "56px", valueWidth: "100px" }, +]; + +function SettingsAccountRowsSkeleton() { + const animation = useSkeletonAnimation(); + + return ( + <> + {ROWS.map((row) => ( +
+ + +
+ ))} + + ); +} + +export default SettingsAccountRowsSkeleton; diff --git a/src/features/settings/components/SettingsAppVersionRow.tsx b/src/features/settings/components/SettingsAppVersionRow.tsx new file mode 100644 index 0000000..c03f8f7 --- /dev/null +++ b/src/features/settings/components/SettingsAppVersionRow.tsx @@ -0,0 +1,19 @@ +import { use } from "react"; + +import { fetchAppVersion } from "@/entities/users/usersApi"; +import SettingsRow from "@/features/settings/components/SettingsRow"; + +// 앱 버전 행(Figma 3524:152356) — 서버는 버전과 최신 여부를 따로 주고, Figma 표기("26.0.1 · +// 최신버전")는 화면이 조합한다. 최신이 아닐 때 문구는 디자인에 없어서 버전만 보여 준다. +function SettingsAppVersionRow() { + const { version, isLatest } = use(fetchAppVersion()); + + return ( + + ); +} + +export default SettingsAppVersionRow; diff --git a/src/features/settings/components/SettingsAppVersionRowSkeleton.tsx b/src/features/settings/components/SettingsAppVersionRowSkeleton.tsx new file mode 100644 index 0000000..e753c25 --- /dev/null +++ b/src/features/settings/components/SettingsAppVersionRowSkeleton.tsx @@ -0,0 +1,29 @@ +import { Skeleton } from "@wanteddev/wds"; + +import { useSkeletonAnimation } from "@/components/ui/ScreenSkeleton"; + +// 앱 버전 행 자리 — 라벨은 화면이 바로 그릴 수 없으니(행 전체가 데이터 컴포넌트) 같이 잡아 둔다. +function SettingsAppVersionRowSkeleton() { + const animation = useSkeletonAnimation(); + + return ( +
+ + +
+ ); +} + +export default SettingsAppVersionRowSkeleton; diff --git a/src/features/settings/components/SettingsRow.tsx b/src/features/settings/components/SettingsRow.tsx new file mode 100644 index 0000000..63795cf --- /dev/null +++ b/src/features/settings/components/SettingsRow.tsx @@ -0,0 +1,113 @@ +import { Typography } from "@wanteddev/wds"; + +import arrowRight from "@/assets/icons/settings/arrow-right.svg"; + +interface SettingsRowBaseProps { + label: string; + /** 오른쪽에 붙는 값 — 계정 정보(이름·이메일·전화번호)와 앱 버전에만 있다 */ + value?: string; + /** 넘기면 행 전체가 버튼이 되고 오른쪽에 화살표가 붙는다 */ + onClick?: () => void; +} + +/** 계정 정보 행 — 라벨 Regular, 값 Medium·Label/Neutral. 색은 고정이라 tone을 받지 않는다. */ +interface SettingsAccountRowProps extends SettingsRowBaseProps { + variant: "account"; +} + +/** 그 외 메뉴·액션 행 — 라벨 Medium, 값 Regular·Label/Alternative(앱 버전) */ +interface SettingsMenuRowProps extends SettingsRowBaseProps { + variant?: "menu"; + /** menu 행 라벨 색 — 로그아웃은 alternative, 회원 탈퇴는 negative */ + tone?: "normal" | "alternative" | "negative"; + /** + * 갈 곳이 아직 없는 행(구글폼 주소 미정). 라벨을 Label/Assistive로 낮추고 실제로 못 누르게 + * `disabled` 버튼으로 그린다 — 누를 수 있어 보이는데 반응이 없는 상태를 없앤다. + */ + disabled?: boolean; +} + +// variant별로 나눠 둬서 `variant="account"`에 tone을 같이 넘기는 잘못된 조합이 타입에서 걸린다. +type SettingsRowProps = SettingsAccountRowProps | SettingsMenuRowProps; + +const TONE_COLOR = { + alternative: "semantic.label.alternative", + negative: "semantic.status.negative", + normal: "semantic.label.neutral", +} as const; + +// Figma: 설정 화면(nodeId 3524:152322)의 행들 — Account Row(3524:152332), Navigation Row +// (3524:152344), Info Row(3524:152356), Action Row(3524:152360)가 같은 높이(20px)·같은 +// justify-between 구조라 한 컴포넌트의 variant/tone으로 합쳤다. 넷의 차이는 라벨 굵기(계정 +// 정보만 Regular)와 라벨 색(로그아웃 alternative·회원 탈퇴 negative)뿐이다. +// 화살표는 Figma에서 앱 버전 행에만 없는데, 그 행만 누를 수 없어서 onClick 유무로 갈린다. +// +// 화살표는 Figma `Arrow`(445:12456) — WDS에 같은 이름이 없고(검색 결과 `Icon/Normal/Arrow *`· +// `Chevron *`뿐) wds-component-usage.md에도 Stream 전용 아이콘으로 분류돼 있어 SVG로 받아 쓴다. +// 색(#C2C4C8 = Atomic/Cool Neutral/90)이 에셋에 구워져 있어 토큰을 따로 추가하지 않았다. +// 비활성 행에서 화살표를 따로 흐리게 하지 않는 건, 이 색이 흰 배경 위 `Label/Assistive` +// (#37383C 28% → 약 #C7C7C9)와 사실상 같은 밝기여서 라벨만 낮춰도 행 전체가 고르게 흐려지기 +// 때문이다. 비활성 색을 Figma가 쓰는 Label/Assistive로 맞춘 이유는 아래 Figma 주석 참고. +// +// 라벨·값은 Typography 기본 태그(span)로 둔다 — onClick이 있으면 행 전체가 + ); +} + +export default SettingsRow; diff --git a/src/features/settings/components/SettingsSection.tsx b/src/features/settings/components/SettingsSection.tsx new file mode 100644 index 0000000..b74dafa --- /dev/null +++ b/src/features/settings/components/SettingsSection.tsx @@ -0,0 +1,42 @@ +import { Typography } from "@wanteddev/wds"; +import type { ReactNode } from "react"; + +interface SettingsSectionProps { + /** 섹션 라벨("계정 정보"·"서비스 정보") — 알림 설정·로그아웃 카드처럼 없는 섹션도 있다 */ + title?: string; + /** Figma 기준 행 사이 간격: 계정 정보·서비스 정보는 20px, 로그아웃은 16px */ + rowGap?: "default" | "compact"; + children: ReactNode; +} + +// Figma: 설정 화면(nodeId 3524:152322)의 흰 카드 — Account Section(3524:152329), +// Notification Settings Section(3524:152343), Service Info Section(3524:152347), +// Logout Section(3524:152359) 넷이 같은 배경·radius·padding이라 하나로 합쳤다. +// 섹션 라벨 유무와 행 간격만 다르다. +function SettingsSection({ + title, + rowGap = "default", + children, +}: SettingsSectionProps) { + return ( +
+ {title && ( + + {title} + + )} +
+ {children} +
+
+ ); +} + +export default SettingsSection; diff --git a/src/features/settings/components/WithdrawalNoticeCard.tsx b/src/features/settings/components/WithdrawalNoticeCard.tsx new file mode 100644 index 0000000..f877f14 --- /dev/null +++ b/src/features/settings/components/WithdrawalNoticeCard.tsx @@ -0,0 +1,38 @@ +import { Typography } from "@wanteddev/wds"; + +interface WithdrawalNoticeCardProps { + title: string; + description: string; +} + +// Figma: 탈퇴 확인 페이지의 Withdrawal Notice Card (메인 컴포넌트 nodeId 3491:151566 +// "Delete Accoount Card"). 네 장이 모두 같은 컴포넌트의 인스턴스고 문구만 다르다. +// 회색 카드(Background/Normal/Alternative)라 설정 화면의 흰 카드(SettingsSection)와는 +// 배경이 반대다 — 같은 카드로 묶지 않고 따로 둔다. +function WithdrawalNoticeCard({ + title, + description, +}: WithdrawalNoticeCardProps) { + return ( +
  • + + {title} + + + {description} + +
  • + ); +} + +export default WithdrawalNoticeCard; diff --git a/src/features/settings/constants/settings.ts b/src/features/settings/constants/settings.ts new file mode 100644 index 0000000..5056fb7 --- /dev/null +++ b/src/features/settings/constants/settings.ts @@ -0,0 +1,61 @@ +// 설정 화면들의 경로 — 홈 헤더·설정 화면이 같이 쓰므로 문자열을 흩지 않고 한곳에 둔다 +// (알림의 `NOTIFICATIONS_PATH`와 같은 방식). 경로와 컴포넌트 이름의 형태를 맞춘다 +// (`/withdrawal` ↔ `Withdrawal*`). +// +// 백엔드 연동 시 확인할 것: `settings`는 용어 사전(terminology.md)에 없는 이름이다. 화면 묶음을 +// 가리키는 말이라 폴더·라우트에만 쓰고, 데이터는 사전에 있는 `users` 도메인으로 뒀다 +// (`entities/users/`). 백엔드가 설정 전용 엔드포인트를 따로 내면 그때 사전에 추가하고 여기를 +// 맞춘다 — 프론트에서 먼저 정하지 않는다(terminology.md 원칙). +export const SETTINGS_PATHS = { + phoneChange: "/settings/phone", + root: "/settings", + withdrawal: "/settings/withdrawal", + withdrawalComplete: "/settings/withdrawal/complete", +}; + +// 로그아웃·회원 탈퇴로 계정을 떠난 뒤 나갈 곳. 둘 다 로그아웃 상태라 로그인/온보딩으로 가야 +// 하는데 그 화면이 아직 없어서(#110에서 들어온다) 지금은 홈으로 보낸다. +// 바꿀 때는 이 값과 함께 useAccountExit 안쪽만 보면 된다. +export const ACCOUNT_EXIT_PATH = "/"; + +// 의견 보내기·개인정보 처리방침은 화면을 따로 만들지 않고 구글폼으로 연결한다. 폼 주소가 아직 +// 없어서 빈 문자열로 두고, 값이 채워졌을 때만 새 탭으로 열도록 호출부에서 분기한다. +export const SETTINGS_EXTERNAL_LINKS = { + feedbackForm: "", + privacyPolicy: "", +}; + +interface WithdrawalNotice { + id: string; + title: string; + description: string; +} + +// Figma: 탈퇴 확인 페이지 Withdrawal Notice List(nodeId 3524:152481) 순서·문구 그대로. +// 렌더마다 배열을 다시 만들지 않도록 모듈 스코프 상수로 둔다. +export const WITHDRAWAL_NOTICES: WithdrawalNotice[] = [ + { + description: + "탈퇴 시 계정 및 회원 정보가 삭제되며, 삭제된 정보는 다시 복구할 수 없어요.", + id: "account-deletion", + title: "회원 정보가 삭제돼요", + }, + { + description: + "신청 중인 행사나 이용 중인 복지 서비스가 있다면 탈퇴 후 이용이 어려울 수 있어요.", + id: "active-services", + title: "이용 내역을 확인해 주세요", + }, + { + description: + "탈퇴 전에 작성한 열린피드백은 탈퇴 후에도 삭제되지 않고 유지돼요.", + id: "feedback-retained", + title: "작성한 열린피드백은 남아 있어요", + }, + { + description: + "탈퇴 후 재가입하더라도 기존 계정의 활동 및 이용 내역은 다시 불러올 수 없어요.", + id: "records-not-restored", + title: "다시 가입해도 이전 기록은 복구되지 않아요", + }, +]; diff --git a/src/features/settings/hooks/useAccountExit.ts b/src/features/settings/hooks/useAccountExit.ts new file mode 100644 index 0000000..110a324 --- /dev/null +++ b/src/features/settings/hooks/useAccountExit.ts @@ -0,0 +1,19 @@ +import { useNavigate } from "react-router-dom"; + +import { ACCOUNT_EXIT_PATH } from "@/features/settings/constants/settings"; + +// 로그아웃 확정과 회원 탈퇴 완료는 "계정에서 나가기"라는 같은 동작이라 한곳에 둔다 — +// 두 화면이 각자 navigate를 들고 있으면 세션 정리를 붙일 때 한쪽을 빠뜨리기 쉽다 +// (사물함 신청의 useLockersApplySubmit과 같은 이유). +// +// #110(로그인·온보딩, PR #112)이 들어오면 **이 훅 안에만** 아래 둘을 붙이면 된다. +// 1. 세션 표시(`stream:auth:logged-in`) 지우기 — 안 지우면 AuthEntryGate를 그냥 통과해서 +// 탈퇴·로그아웃 뒤에도 홈이 그대로 보인다 +// 2. ACCOUNT_EXIT_PATH를 "/auth"로 바꾸기 +// #112의 entities/auth/session.ts에는 아직 표시를 지우는 함수가 없어서 거기에 추가가 필요하다. +export function useAccountExit() { + const navigate = useNavigate(); + + // 떠난 계정 화면으로 뒤로가기로 돌아오지 않게 히스토리를 치환한다 + return () => navigate(ACCOUNT_EXIT_PATH, { replace: true }); +} diff --git a/src/features/settings/types.ts b/src/features/settings/types.ts new file mode 100644 index 0000000..89ece72 --- /dev/null +++ b/src/features/settings/types.ts @@ -0,0 +1,5 @@ +// 탈퇴 확인 화면이 완료 화면으로 넘기는 값. 완료 화면이 주소로 바로 열리는 걸 막는 표시다 +// (사물함 신청의 LockersApplyCompleteState와 같은 방식). +export interface WithdrawalCompleteState { + withdrawn: true; +} diff --git a/src/lib/openExternalLink.ts b/src/lib/openExternalLink.ts new file mode 100644 index 0000000..9a0f6cd --- /dev/null +++ b/src/lib/openExternalLink.ts @@ -0,0 +1,21 @@ +// 서비스 밖 주소(구글폼 등)를 여는 자리. 화면은 이 함수만 부르고, 여는 방식이 바뀌면 여기만 고친다. +// +// **앱 WebView에서는 이 구현이 맞지 않을 수 있다.** 앱 안에서 `window.open`은 막히거나 +// (아무 일도 안 일어남) WebView 안에서 그대로 열려서 사용자가 외부 페이지에 갇힌다 — 앱에는 +// 주소창도 뒤로가기도 없다. 제대로 하려면 앱 셸에 "이 주소를 시스템 브라우저로 열어 달라"고 +// 넘겨야 한다. 재료는 이미 `lib/bridge/`에 있다. +// +// if (isInAppShell()) { +// postBridgeMessage(OPEN_EXTERNAL_LINK_MESSAGE_TYPE, { url }); +// return; +// } +// +// 지금 넣지 않은 이유: 브리지 메시지는 **앱 쪽에 받는 핸들러가 같이 들어가야** 동작한다 +// (`bridge.ts` 주석의 메시지 추가 절차 3단계). 웹에만 넣으면 앱이 모르는 메시지로 조용히 +// 버려서 지금과 결과가 같다. 게다가 연결할 구글폼 주소가 아직 없어서(SETTINGS_EXTERNAL_LINKS가 +// 비어 있고 그동안 해당 행은 비활성이다) 급할 것도 없다. +// +// 주소와 앱 작업이 준비되면 위 분기를 여기에 채운다 — 호출부는 건드릴 필요가 없다. +export function openExternalLink(url: string): void { + window.open(url, "_blank", "noopener,noreferrer"); +} diff --git a/src/lib/phone.ts b/src/lib/phone.ts new file mode 100644 index 0000000..5aed1e1 --- /dev/null +++ b/src/lib/phone.ts @@ -0,0 +1,35 @@ +// 전화번호 입력 공용 로직 — 설정의 전화번호 변경과 온보딩 연락처 입력(#110)이 같이 쓴다. +// React와 무관한 순수 함수라 화면 파일이 아니라 여기에 둔다. + +/** `010-1234-5678` 13자. 입력칸 maxLength가 이 값에 묶인다. */ +export const PHONE_NUMBER_MAX_LENGTH = 13; + +/** 하이픈을 뺀 숫자 자릿수 */ +const PHONE_DIGIT_COUNT = 11; + +// 통과 기준을 `010`으로 고정한다. 01X(011·016~019)는 2G 종료와 함께 전부 010으로 번호이동이 +// 끝나서 지금 새로 입력될 일이 없다. 서버 검증 규칙이 더 넓으면 이 정규식만 넓히면 된다. +const PHONE_NUMBER_PATTERN = /^010-\d{4}-\d{4}$/; + +/** 입력값에서 숫자만 꺼낸다(최대 11자리) */ +export function toPhoneDigits(value: string): string { + return value.replace(/\D/g, "").slice(0, PHONE_DIGIT_COUNT); +} + +// 입력하는 대로 하이픈을 끼운다(Figma 입력 중 상태 3524:152465가 `010-1234-5` 형태). +// 숫자가 아닌 글자를 섞어 쳐도 숫자만 걸러서 포맷한다. +export function formatPhoneNumber(value: string): string { + const digits = toPhoneDigits(value); + 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)}`; +} + +/** 포맷된 번호가 저장할 수 있는 형태인지 — 버튼 활성화 조건이다 */ +export function isValidPhoneNumber(value: string): boolean { + return PHONE_NUMBER_PATTERN.test(value); +}