diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..4ae8d8f --- /dev/null +++ b/.env.example @@ -0,0 +1,4 @@ +# K-CONNECT에 등록한 앱의 client_id (K-계정 관리 페이지의 OAuth2 애플리케이션) +# 복사해서 .env로 만들고 값을 채운다 — .env는 커밋하지 않는다. +# 허가된 Redirect URI에 http://localhost:5173/oauth/kconnect(개발)와 배포 주소의 /oauth/kconnect가 등록돼 있어야 한다. +VITE_KCONNECT_CLIENT_ID= diff --git a/.npmrc b/.npmrc index 366952d..0b5bd91 100644 --- a/.npmrc +++ b/.npmrc @@ -1 +1,2 @@ -@wanteddev:registry=https://npm.pkg.github.com/ \ No newline at end of file +@wanteddev:registry=https://npm.pkg.github.com/ +@billilge:registry=https://npm.pkg.github.com/ diff --git a/Dockerfile b/Dockerfile index 5bf5d1a..653ea7d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -8,7 +8,8 @@ WORKDIR /app RUN corepack enable # 의존성 캐시 최적화를 위해 매니페스트 먼저 복사 -COPY package.json pnpm-lock.yaml .npmrc ./ +# pnpm-workspace.yaml의 overrides가 lockfile과 같아야 --frozen-lockfile이 통과한다 +COPY package.json pnpm-lock.yaml pnpm-workspace.yaml .npmrc ./ # @wanteddev 패키지는 GitHub Packages 사설 레지스트리에서 받으므로 인증 토큰 필요. # BuildKit secret으로 주입하고, 같은 RUN 안에서 .npmrc를 원복해 토큰이 레이어에 남지 않도록 한다. diff --git a/docs/conventions/terminology.md b/docs/conventions/terminology.md index 0620a26..0989f7a 100644 --- a/docs/conventions/terminology.md +++ b/docs/conventions/terminology.md @@ -12,7 +12,7 @@ | 한글 도메인명 | 코드 용어 | | --- | --- | -| 공통 | `auth` | +| 공통(스플래시·로그인·온보딩) | `auth` | | 사용자 | `users` | | 게시판(공지) | `notices` | | 게시판(열린피드백) | `feedbacks` | diff --git a/docs/conventions/wds-component-usage.md b/docs/conventions/wds-component-usage.md index 4beaefb..fb6d3a9 100644 --- a/docs/conventions/wds-component-usage.md +++ b/docs/conventions/wds-component-usage.md @@ -649,3 +649,48 @@ Figma는 헤더 배경이 없어서 배너 그림자(`Shadow/Spread/Small`, 60px ### 설정 화면의 "전체 알림"은 상태를 따로 두지 않는다 Figma 초기 상태가 전체 꺼짐 + 빌릴게만 켜짐이라 "전체"는 독립된 값이 아니라 카테고리가 모두 켜졌는지로 파생한다. 누르면 네 카테고리를 같은 값으로 맞추고, 하나라도 끄면 전체가 꺼진다. 값은 화면 state뿐이라 새로고침하면 초기값으로 돌아간다(서버 API 연동 때 교체). + +## 스플래시·로그인·온보딩(`3658:112096` / `3738:73115`, 화면설계서 `3747:74283` / `3747:74298`) 구현 중 확정된 매핑 + +코드는 `src/features/auth/`, `src/entities/auth/` 참고. + +| Figma | 코드 | 확인 내용 | +|---|---|---| +| `Control/Checkbox` (`440:11057`) | `Checkbox size="medium"` | Figma Control이 24px(박스 18 + 패딩 3)라 **`medium`**이다. 행사 신청 폼에서 쓴 `small`(20px, 패딩 2)이 아니다 — `checkbox/style.js`의 `checkboxSizeStyle`로 확인. 라벨 `x=52`(24 + 간격 8)가 Figma와 일치한다 | +| `Textinput/Textfield` (`445:8591`) | `TextField` | 높이 48, 모서리 12, 입력 글자 16px이 Figma와 일치한다. 숫자 키패드(`inputMode="numeric"`, `type="tel"`)와 자동 하이픈은 화면에서 처리한다 | +| `Divider/Divider` (`445:4786`) | `Divider color="semantic.line.normal.normal"` | 약관 동의 목록의 전체 동의/개별 항목 구분선(1px, `Line/Normal/Normal`) | +| `divider(8px)` (`1256:77295`) | `Divider color="semantic.line.normal.alternative"` | 약관 상세의 조항 구분선(1px, `Line/Normal/Alternative`). **이름이 비슷한 위 구분선과 색이 다르다** | +| `Action Area/Action Area` | `ActionArea` + `ActionAreaButton sx={{ paddingBlock: "16px" }}` | "Action Area 메인 버튼 높이" 규칙 그대로다. 비활성(`다음`)은 WDS disabled 스타일이 Figma `Interaction/Disable` 배경과 맞는다 | +| `Icon/Normal/Chevron Left` | `IconChevronLeft` | 뒤로가기 — 다른 `variant="normal"` 화면과 같다 | +| `Circle Check Motion` (`1712:192849`) | `CompleteCheck` + 공용 `ResultScreen` | 가입 완료 화면은 신청 완료 화면과 같은 뼈대(닫기 X, 체크 모션, 제목·설명, 하단 버튼)다. 새로 만들지 않고 재사용했고, 체크–문구 간격 8이라 `illustrationGap={8}` | + +### 반례 — `Pagination/Dots`는 WDS `PaginationDots`와 크기가 다르다 + +Figma 닷은 지름 6px·간격 6px(중심 간격 12px)이다. WDS `PaginationDots`는 `small`이 8px·간격 8px(중심 간격 16px), `medium`이 10px이고, 닷 크기를 CSS 변수(`--wds-pagination-dot-size`)로만 바꿀 수 있어 내부 오버라이드가 된다(`pagination-dots/style.js` 확인). 그래서 `AuthPaginationDots`를 로컬로 짰다 — 현재 닷은 `Label/Normal`, 나머지는 같은 색 16% 불투명도. + +### 반례 — `K-CONNECT로 시작하기` 버튼은 WDS `ActionAreaButton`이 아니다 + +배경이 K-CONNECT 브랜드색 `#004F9F`(Figma에서도 변수가 아니라 raw hex)이고 높이 57px·좌측 로고가 있어 WDS 버튼의 색·크기 스케일로 만들 수 없다. `AuthKConnectButton`을 로컬로 두고 색은 `index.css`에 `--color-k-connect` 토큰으로 추가했다. + +### 로그인 문구 블록의 Figma 아티팩트 — `Login Copy` 프레임이 50px로 고정돼 있다 + +문구(제목 28 + 간격 4 + 설명 22 = 54px)가 프레임보다 4px 넘친다. Figma 좌표상 문구–닷 간격이 28px로 보이지만 실제 문구 끝에서 닷까지는 24px이라, 닷 위치(y=538)를 기준으로 `gap-6`을 썼다. + +### 로그인 위치는 앱 WebView 영역 기준이다 + +Figma 프레임의 상태 표시줄(54px)과 홈 바(34px)는 앱 셸 몫이라 웹에는 없다. 로그인 브랜드 헤더 y=126 → 웹에서 72 같은 식으로 그 영역을 뺀 값이다. 브라우저에서 열면(상태 표시줄 없이 화면 전체가 웹) 같은 값이 위로 치우쳐 보여서, `isInAppShell()`이 아닐 때만 로그인 본문을 버튼 위 영역의 정가운데(auto 마진)에 둔다. 앱 WebView에서는 위 Figma 값 그대로다. 스플래시는 웹에서 쓰지 않아 구현하지 않았다. + +### K-CONNECT 로그인은 `@billilge/oauth-kconnect`(웹용 `/react`)로 한다 + +`K-CONNECT로 시작하기`는 `useKConnectLogin({ clientId }).login()`으로 K-CONNECT 인증 사이트로 페이지를 이동시키고(PKCE, `state`는 패키지가 sessionStorage에 보관), 인증 후 K-CONNECT가 되돌려 보내는 `/oauth/kconnect`(`KConnectCallbackScreen`)가 `useKConnectCallback()`으로 `{ code, codeVerifier, redirectUri }`를 받는다. 이 경로는 K-CONNECT에 등록한 redirect URI이자 패키지가 정한 값이라 바꾸지 않는다. 받은 값은 서버(`POST /auth/kconnect`)로 보내 서비스 로그인을 마치는데, 서버 주소·응답 형식이 아직 없어서 `entities/auth/authApi.ts`의 `exchangeKConnectCode`가 응답만 흉내 낸다. 신규 사용자는 `/auth`로 이동하면서 이름을 navigate state로 넘겨 로그인 퍼널이 `initial`(주소에 단계가 없을 때만 쓰임)로 연락처 단계에서 시작하고, 기존 사용자는 홈으로 간다. 취소·오류(국민대 학생이 아니어서 거부된 경우 포함)나 서버 실패는 `/auth`로 돌아와 실패 토스트를 띄운다. 어느 경우든 주소창에 `code`가 남지 않도록 `replace`로 이동한다. + +- **설정:** `client_id`는 환경 변수 `VITE_KCONNECT_CLIENT_ID`(`.env.example` 참고)로 받는다. K-CONNECT의 "허가된 Redirect URI"에 `http://localhost:5173/oauth/kconnect`(개발)와 배포 주소의 `/oauth/kconnect`가 등록돼 있어야 한다 — 폰으로 `192.168.x.x:5173`처럼 접속하면 등록되지 않은 주소라 로그인이 거부된다. +- **설치:** 패키지는 GitHub Packages 비공개 배포라 `.npmrc`에 `@billilge:registry`가 필요하고 `read:packages` 토큰이 있어야 한다(토큰은 파일에 쓰지 않고 환경 변수·사용자 npmrc로 둔다). 패키지가 Expo용 `expo-auth-session`을 optional peer로 선언하는데 pnpm `autoInstallPeers`가 설치까지 해서 웹에 필요 없는 Expo 의존성 수백 개가 딸려 오므로, `pnpm-workspace.yaml`의 `overrides`로 뺐다. +- **로그인 표시 지우기:** 로그아웃·회원 탈퇴처럼 계정을 떠나는 흐름은 `entities/auth/session.ts`의 `clearLoggedIn()`을 불러야 한다. 지우지 않으면 `/`의 `AuthEntryGate`를 그대로 통과해서 방금 떠난 계정으로 홈이 보인다(설정 화면 #97이 호출한다). +- **연락처는 010 고정:** 가입 연락처는 `010`으로 시작하는 숫자 11자리여야 `다음`이 켜진다(`isValidPhoneDigits`). 설정의 전화번호 변경(#97)과 같은 기준이라, 구 번호(011·016 등)로 가입한 뒤 설정에서 같은 번호로 바꾸지 못하는 일이 없다. +- **K-CONNECT 인증 우회(개발 서버 전용):** `/auth?mockKConnect=new`(신규 → 온보딩) 또는 `?mockKConnect=existing`(기존 → 홈)으로 열고 `K-CONNECT로 시작하기`를 누르면 인증 사이트로 가지 않고 인증된 것으로 친다. `import.meta.env.DEV`로 가려서 배포 빌드에는 들어가지 않는다(번들에 `mockKConnect`가 없음을 확인). +- **목 QA(실 서버가 붙으면 사라짐):** 인증 후 콜백 주소에 `&mockLogin=existing`을 붙이면 기존 사용자, `&mockLogin=fail`이면 서버 실패로 처리된다. 취소·오류는 `/oauth/kconnect?error=access_denied`로 확인한다. K-CONNECT 계정으로 실제 로그인은 이 환경에서 확인하지 못했다. + +### 로그인·가입은 useFunnel 퍼널 하나(`AuthSignupScreen`)다 + +로그인 → 연락처 → 약관 동의 → 약관 상세 → 가입 완료를 `@use-funnel/react-router-dom`의 `useFunnel`로 묶었다(라우트 `/auth`, 단계는 `?auth.step=`). 퍼널 파일(`AuthSignupScreen.tsx`)은 단계 전환(어느 단계에서 어디로, 무슨 값을 넘기는지)만 맡고, 단계 화면은 `features/auth/steps/`에 파일 하나씩(`AuthLoginStep`, `AuthPhoneStep`, `AuthTermsStep`, `AuthTermsDetailStep`, `AuthCompleteStep`) 나눠 두며 전환은 콜백 prop으로 받는다. 단계·context 타입과 `guard`는 `constants/authSignupFunnel.ts`, 단계 화면이 함께 쓰는 뒤로가기 버튼·하단 버튼은 `components/`(`AuthBackButton`, `AuthBottomAction`)에 있다. 단계 사이에 넘기는 이름·연락처·약관 동의는 퍼널 context가 들고 있어서, 약관 상세(`history.push`)를 다녀와도 동의 상태가 유지된다(별도 저장소·navigate state 불필요). 동의 체크나 연락처 입력은 단계를 바꾸지 않고 `history.replace`로 context만 갈아 끼운다(히스토리가 늘지 않음). 앞 단계 값이 없이 중간 단계로 들어오면(새로고침·주소 직접 입력) 각 단계의 `guard`가 걸러서 로그인으로 돌려보낸다. 단계가 바뀌는 이동(`push`, 가입 완료로의 `replace`)에는 슬라이드(View Transition)를 켜고, 체크·연락처 입력처럼 context만 갈아 끼우는 `replace`에는 켜지 않는다. 라이브러리 기본 `useFunnel`은 옵션 이름을 `unstable_viewTransition`으로 넘기는데 React Router 7.18은 `viewTransition`만 알아서(소스에 이전 이름이 없다) 슬라이드가 무시된다. 그래서 `src/lib/useFunnel.ts`에 같은 라우터 어댑터를 `createUseFunnel`로 직접 만들고 옵션 이름만 바꿨다(라이브러리의 "라우터 직접 만들기" 가이드 방식) — 라이브러리를 올릴 때 이 이름이 고쳐졌는지 확인하고 고쳐졌다면 이 파일을 지운다. diff --git a/package.json b/package.json index 8f3006b..692e636 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,9 @@ "check:fix": "biome check --write ." }, "dependencies": { + "@billilge/oauth-kconnect": "^0.2.0", "@ncdai/react-wheel-picker": "^1.2.3", + "@use-funnel/react-router-dom": "^0.0.16", "@wanteddev/wds": "^3.12.0", "@wanteddev/wds-icon": "^3.12.0", "lottie-react": "^3.1.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 80c978e..ee84be3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4,13 +4,22 @@ settings: autoInstallPeers: true excludeLinksFromLockfile: false +overrides: + expo-auth-session: '-' + importers: .: dependencies: + '@billilge/oauth-kconnect': + specifier: ^0.2.0 + version: 0.2.0(react@19.2.8) '@ncdai/react-wheel-picker': specifier: ^1.2.3 version: 1.2.3(react@19.2.8) + '@use-funnel/react-router-dom': + specifier: ^0.0.16 + version: 0.0.16(react-router-dom@7.18.3(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8) '@wanteddev/wds': specifier: ^3.12.0 version: 3.12.0(@emotion/cache@11.14.0)(@emotion/react@11.14.0(@types/react@19.2.18)(react@19.2.8))(@emotion/serialize@1.3.3)(@emotion/utils@1.4.2)(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) @@ -105,6 +114,11 @@ packages: resolution: {integrity: sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==} engines: {node: '>=6.9.0'} + '@billilge/oauth-kconnect@0.2.0': + resolution: {integrity: sha512-XoM3rXaNU8yxrZojzDfOI9siQKLqe9ejC16DgqLNB2SN3kn3lJJpG87x4rySWg4jqpP/ydlFom09XKev6cKBYQ==, tarball: https://npm.pkg.github.com/download/@billilge/oauth-kconnect/0.2.0/8d37ed0baeab3a0d384b2a43b73cf3530c78f046} + peerDependencies: + react: '*' + '@biomejs/biome@2.5.11': resolution: {integrity: sha512-Tj0dnkLPdW0ASjHfj2D/ZkkvPU2wrFmnE1jWTD2xzV1ycapV1DutbYXk4NDnR3rYTi1ZCbNFD4G2gRMEY65WaA==} engines: {node: '>=14.21.3'} @@ -730,6 +744,17 @@ packages: '@types/react@19.2.18': resolution: {integrity: sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==} + '@use-funnel/core@0.1.0': + resolution: {integrity: sha512-KEjBKTbOh5wd6CQZ1jYWhI6KuGrI9Pjn9CkmUgAYzonsyZE0lsiZ2/1RwsaWcxVO5RJBgfwCM8dRpibwm7An9w==} + peerDependencies: + react: '>=16.8' + + '@use-funnel/react-router-dom@0.0.16': + resolution: {integrity: sha512-QH1N7+82MDmop3QASaXLlnAYx9ZL1D2lLnFQrW95UPUdctbiY2ZcydIn36LdzpQisj+fZcJUU13haTkACY6oHA==} + peerDependencies: + react: '>=16.8' + react-router-dom: '>=6' + '@vitejs/plugin-react@6.1.1': resolution: {integrity: sha512-yxLaQV9gkhS8ezJqCM6+ndU7mDY6gqAg75NQ+0IjwEI8IYOmQCgkRwHKVSfWXW076DsqMo0Dk+0FK1U+M5RgFw==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1362,6 +1387,10 @@ snapshots: '@babel/helper-string-parser': 7.29.7 '@babel/helper-validator-identifier': 7.29.7 + '@billilge/oauth-kconnect@0.2.0(react@19.2.8)': + dependencies: + react: 19.2.8 + '@biomejs/biome@2.5.11': optionalDependencies: '@biomejs/cli-darwin-arm64': 2.5.11 @@ -1861,6 +1890,16 @@ snapshots: dependencies: csstype: 3.2.3 + '@use-funnel/core@0.1.0(react@19.2.8)': + dependencies: + react: 19.2.8 + + '@use-funnel/react-router-dom@0.0.16(react-router-dom@7.18.3(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8)': + dependencies: + '@use-funnel/core': 0.1.0(react@19.2.8) + react: 19.2.8 + react-router-dom: 7.18.3(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@vitejs/plugin-react@6.1.1(vite@8.2.2(@types/node@24.13.3)(jiti@2.7.0))': dependencies: '@rolldown/pluginutils': 1.0.1 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 0000000..91c612d --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,5 @@ +# @billilge/oauth-kconnect는 Expo 앱용 의존성(expo-auth-session)을 optional peer로 선언하는데, pnpm의 +# autoInstallPeers가 그것까지 설치해서 웹에 필요 없는 Expo 의존성 수백 개가 딸려 온다. +# 이 앱은 웹용 진입점(@billilge/oauth-kconnect/react)만 쓰므로 설치에서 뺀다. +overrides: + expo-auth-session: "-" diff --git a/src/app/router.tsx b/src/app/router.tsx index 1604574..cae7d01 100644 --- a/src/app/router.tsx +++ b/src/app/router.tsx @@ -9,6 +9,9 @@ import ArchivesDetailScreen from "@/features/archives/ArchivesDetailScreen"; import ArchivesListScreen from "@/features/archives/ArchivesListScreen"; import ArchivesPhotosScreen from "@/features/archives/ArchivesPhotosScreen"; import ArchivesPhotoViewerScreen from "@/features/archives/ArchivesPhotoViewerScreen"; +import AuthEntryGate from "@/features/auth/AuthEntryGate"; +import AuthSignupScreen from "@/features/auth/AuthSignupScreen"; +import KConnectCallbackScreen from "@/features/auth/KConnectCallbackScreen"; import BililgeListScreen from "@/features/bililge/BililgeListScreen"; import ChatEntryScreen from "@/features/chat/ChatEntryScreen"; import EventsApplicationClosedScreen from "@/features/events/EventsApplicationClosedScreen"; @@ -40,7 +43,12 @@ const routes = [ { children: [ { - element: , + // 앱 첫 진입점 — 로그인한 기록이 없으면 스플래시(→ 로그인 화면)로 보낸다 + element: ( + + + + ), path: "/", }, { @@ -155,6 +163,25 @@ const routes = [ } satisfies ScreenRouteHandle, path: "/notifications/settings", }, + { + element: , + // 로그인·온보딩 퍼널 하나가 모든 단계(로그인, 연락처, 약관 동의, 약관 상세, 가입 완료)를 그린다. + // Figma 로그인·온보딩에는 Bottom Nav가 없고 배경이 흰 면이다 + handle: { + background: "normal", + hasBottomNav: false, + } satisfies ScreenRouteHandle, + path: "/auth", + }, + { + element: , + // K-CONNECT 인증 후 되돌아오는 redirect URI — 경로는 K-CONNECT에 등록한 값(@billilge/oauth-kconnect 규약)이다 + handle: { + background: "normal", + hasBottomNav: false, + } satisfies ScreenRouteHandle, + path: "/oauth/kconnect", + }, { element: , // Figma 챗봇 진입 화면에는 Bottom Nav가 없다(뒤로가기로 홈에 복귀). 흰 배경 위에 diff --git a/src/assets/icons/auth/arrow-right.svg b/src/assets/icons/auth/arrow-right.svg new file mode 100644 index 0000000..be36674 --- /dev/null +++ b/src/assets/icons/auth/arrow-right.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/assets/icons/auth/illustration-circle.svg b/src/assets/icons/auth/illustration-circle.svg new file mode 100644 index 0000000..eb65e23 --- /dev/null +++ b/src/assets/icons/auth/illustration-circle.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/src/assets/icons/auth/k-connect-logo.svg b/src/assets/icons/auth/k-connect-logo.svg new file mode 100644 index 0000000..6e6dde8 --- /dev/null +++ b/src/assets/icons/auth/k-connect-logo.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/assets/icons/auth/stream-logo.svg b/src/assets/icons/auth/stream-logo.svg new file mode 100644 index 0000000..c3b4c08 --- /dev/null +++ b/src/assets/icons/auth/stream-logo.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/src/assets/icons/auth/stream-wordmark.svg b/src/assets/icons/auth/stream-wordmark.svg new file mode 100644 index 0000000..17c3d69 --- /dev/null +++ b/src/assets/icons/auth/stream-wordmark.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/src/assets/images/auth/login-events.png b/src/assets/images/auth/login-events.png new file mode 100644 index 0000000..0525249 Binary files /dev/null and b/src/assets/images/auth/login-events.png differ diff --git a/src/assets/images/auth/login-feedbacks.png b/src/assets/images/auth/login-feedbacks.png new file mode 100644 index 0000000..9361e6e Binary files /dev/null and b/src/assets/images/auth/login-feedbacks.png differ diff --git a/src/assets/images/auth/login-notices.png b/src/assets/images/auth/login-notices.png new file mode 100644 index 0000000..7140da9 Binary files /dev/null and b/src/assets/images/auth/login-notices.png differ diff --git a/src/entities/auth/authApi.ts b/src/entities/auth/authApi.ts new file mode 100644 index 0000000..c7077d8 --- /dev/null +++ b/src/entities/auth/authApi.ts @@ -0,0 +1,65 @@ +import type { KConnectUser, OnboardingSubmission } from "@/entities/auth/types"; + +const MOCK_DELAY_MS = 700; + +const MOCK_NEW_USER: KConnectUser = { isNewUser: true, name: "이예담" }; +const MOCK_EXISTING_USER: KConnectUser = { isNewUser: false, name: "이예담" }; + +function delay(): Promise { + return new Promise((resolve) => { + setTimeout(resolve, MOCK_DELAY_MS); + }); +} + +// 서버로 보내는 K-CONNECT 인증 결과 — @billilge/oauth-kconnect가 돌려주는 값 그대로다. +export interface KConnectCredentials { + code: string; + codeVerifier: string; + redirectUri: string; +} + +// K-CONNECT 로그인 화면을 거쳐 돌려받은 `{ code, codeVerifier, redirectUri }`를 서버로 보내 서비스 로그인을 마치는 자리다. +// 서버는 POST /auth/kconnect로 이 값을 받아 토큰 교환, 재학생 확인, 서비스 토큰 발급을 한다(oauth-kconnect 서버 가이드). +// 서버 주소와 응답 형식(서비스 토큰, 신규 사용자 여부)이 정해지기 전까지는 응답만 흉내 낸다. +// 로그인은 요청마다 결과가 달라지는 동작이라 요청 단위로 Promise를 재사용하는 mockupApi를 쓰지 않는다. +// 실패·기존 사용자 흐름을 화면에서 확인할 수 있게 콜백 주소의 쿼리로 결과를 고른다(목 전용, 실 API가 붙으면 사라진다): +// /oauth/kconnect?...&mockLogin=fail → 서버 실패(로그인 화면의 토스트) +// /oauth/kconnect?...&mockLogin=existing → 기존 사용자(바로 홈) +// 그 외 → 신규 사용자(온보딩) +export async function exchangeKConnectCode( + _credentials: KConnectCredentials, +): Promise { + await delay(); + const mockLogin = new URLSearchParams(window.location.search).get( + "mockLogin", + ); + if (mockLogin === "fail") { + throw new Error("K-CONNECT 로그인에 실패했어요."); + } + return mockLogin === "existing" ? MOCK_EXISTING_USER : MOCK_NEW_USER; +} + +// 개발 서버에서 K-CONNECT 인증을 거치지 않고 "인증된 것으로 치고" 로그인 이후 흐름을 확인하는 우회다. +// `/auth?mockKConnect=new`(신규 사용자 → 온보딩), `/auth?mockKConnect=existing`(기존 사용자 → 홈). +// 호출하는 쪽이 `import.meta.env.DEV`일 때만 부르므로 배포 빌드에는 들어가지 않는다. +export type MockKConnectKind = "new" | "existing"; + +export function isMockKConnectKind( + value: string | null, +): value is MockKConnectKind { + return value === "new" || value === "existing"; +} + +export async function mockKConnectLogin( + kind: MockKConnectKind, +): Promise { + await delay(); + return kind === "existing" ? MOCK_EXISTING_USER : MOCK_NEW_USER; +} + +// 온보딩 정보를 서버에 저장하는 자리 — 실 API가 붙을 때까지는 응답만 흉내 낸다. +export async function submitOnboarding( + _submission: OnboardingSubmission, +): Promise { + await delay(); +} diff --git a/src/entities/auth/session.ts b/src/entities/auth/session.ts new file mode 100644 index 0000000..eaa5344 --- /dev/null +++ b/src/entities/auth/session.ts @@ -0,0 +1,31 @@ +// "이 탭에서 로그인을 이미 거쳤다"는 표시만 sessionStorage에 남긴다(이름 같은 로그인 정보는 저장하지 않는다). +// 같은 탭에서는 새로고침해도 홈이 유지되고, 탭·브라우저를 닫았다가 새로 열면 사라져서 스플래시 → 로그인 화면부터 +// 다시 시작한다. 실 인증이 붙으면 서버가 내려 준 세션(쿠키·토큰)으로 바뀐다. +const LOGGED_IN_STORAGE_KEY = "stream:auth:logged-in"; + +export function isLoggedIn(): boolean { + try { + return window.sessionStorage.getItem(LOGGED_IN_STORAGE_KEY) !== null; + } catch (error) { + console.warn("로그인 표시를 읽지 못했어요.", error); + return false; + } +} + +export function markLoggedIn(): void { + try { + window.sessionStorage.setItem(LOGGED_IN_STORAGE_KEY, "1"); + } catch (error) { + console.warn("로그인 표시를 저장하지 못했어요.", error); + } +} + +// 로그아웃·회원 탈퇴처럼 계정을 떠날 때 부른다 — 표시를 지우지 않으면 `/`의 로그인 가드(AuthEntryGate)를 그대로 통과해서 +// 방금 떠난 계정으로 홈이 보인다. +export function clearLoggedIn(): void { + try { + window.sessionStorage.removeItem(LOGGED_IN_STORAGE_KEY); + } catch (error) { + console.warn("로그인 표시를 지우지 못했어요.", error); + } +} diff --git a/src/entities/auth/types.ts b/src/entities/auth/types.ts new file mode 100644 index 0000000..7f1ca5c --- /dev/null +++ b/src/entities/auth/types.ts @@ -0,0 +1,13 @@ +// K-CONNECT 인증이 돌려주는 사용자 — 신규 사용자면 온보딩(연락처·약관)을 거친다. +export interface KConnectUser { + /** K-CONNECT가 알려 준 이름 — 온보딩 인삿말에 쓴다 */ + name: string; + isNewUser: boolean; +} + +export interface OnboardingSubmission { + name: string; + phone: string; + /** 선택 약관(정보 알림 수신) 동의 여부 — 필수 약관 둘은 동의해야만 여기까지 온다 */ + hasAgreedNotifications: boolean; +} diff --git a/src/features/auth/AuthEntryGate.tsx b/src/features/auth/AuthEntryGate.tsx new file mode 100644 index 0000000..7ce5857 --- /dev/null +++ b/src/features/auth/AuthEntryGate.tsx @@ -0,0 +1,23 @@ +import type { ReactNode } from "react"; +import { Navigate } from "react-router-dom"; + +import { isLoggedIn } from "@/entities/auth/session"; +import { AUTH_PATH } from "@/features/auth/constants/auth"; + +interface AuthEntryGateProps { + children: ReactNode; +} + +// 앱 첫 진입점(`/`)에서만 로그인 여부를 본다 — 이 탭에서 로그인한 기록이 없으면 로그인·온보딩 퍼널(`/auth`)로 +// 보내고(replace라 뒤로가기로 돌아오지 않는다), 있으면 홈을 그대로 보여 준다. +// 다른 화면(`/events` 등)으로 직접 들어오는 경로에는 가드를 걸지 않는다. +// 로그인 표시는 sessionStorage에만 있어서, 같은 탭의 새로고침은 홈이 유지되고 탭·브라우저를 새로 열 때만 +// 이 가드가 로그인 화면으로 보낸다. +function AuthEntryGate({ children }: AuthEntryGateProps) { + if (!isLoggedIn()) { + return ; + } + return children; +} + +export default AuthEntryGate; diff --git a/src/features/auth/AuthSignupScreen.tsx b/src/features/auth/AuthSignupScreen.tsx new file mode 100644 index 0000000..1d9c193 --- /dev/null +++ b/src/features/auth/AuthSignupScreen.tsx @@ -0,0 +1,111 @@ +import { useLocation, useNavigate } from "react-router-dom"; + +import { submitOnboarding } from "@/entities/auth/authApi"; +import { markLoggedIn } from "@/entities/auth/session"; +import { INITIAL_TERMS_AGREEMENTS } from "@/features/auth/constants/auth"; +import { + AUTH_SIGNUP_STEP_OPTIONS, + type AuthSignupSteps, +} from "@/features/auth/constants/authSignupFunnel"; +import AuthCompleteStep from "@/features/auth/steps/AuthCompleteStep"; +import AuthLoginStep from "@/features/auth/steps/AuthLoginStep"; +import AuthPhoneStep from "@/features/auth/steps/AuthPhoneStep"; +import AuthTermsDetailStep from "@/features/auth/steps/AuthTermsDetailStep"; +import AuthTermsStep from "@/features/auth/steps/AuthTermsStep"; +import { readAuthLocationState } from "@/features/auth/utils/authLocationState"; +import { useFunnel } from "@/lib/useFunnel"; + +// 로그인과 가입(연락처 → 약관 동의 → 약관 상세 → 가입 완료)을 useFunnel 하나로 묶은 화면이다. 단계 화면은 `steps/`에 +// 하나씩 나눠 두고, 이 파일은 단계 전환(어느 단계에서 어디로, 무슨 값을 넘기는지)만 맡는다. +// 단계는 `/auth?auth.step=...`으로 나뉘고 단계마다 브라우저 뒤로가기가 한 단계씩 돌아간다. 단계 사이에 넘기는 값 +// (이름·연락처·약관 동의)은 퍼널 context가 들고 있어서 약관 상세에서 돌아와도 유지된다. +// 스플래시는 웹에서 쓰지 않아 이 퍼널에 없다. + +// 단계가 바뀌는 이동에만 슬라이드를 켠다 — 체크박스·연락처 입력처럼 같은 단계의 context만 갈아 끼우는 replace에는 +// 켜지 않는다. 뒤로가기는 React Router가 앞선 이동을 기억해 반대 방향으로 자동 적용한다(coding-style.md 라우팅). +const SLIDE = { viewTransition: true }; + +function AuthSignupScreen() { + const navigate = useNavigate(); + const location = useLocation(); + // K-CONNECT 콜백(KConnectCallbackScreen)이 신규 사용자를 이름과 함께 보내면 로그인을 건너뛰고 연락처 단계에서 시작한다. + // `initial`은 주소에 단계가 없을 때만 쓰여서, 단계가 생긴 뒤(약관 동의 등)에는 영향이 없다. + const { kConnectName, loginFailed } = readAuthLocationState(location.state); + const funnel = useFunnel({ + id: "auth", + initial: kConnectName + ? { context: { name: kConnectName }, step: "phone" } + : { context: {}, step: "login" }, + steps: AUTH_SIGNUP_STEP_OPTIONS, + }); + + const goHome = () => navigate("/", { replace: true }); + + return ( + } + login={({ history }) => ( + { + if (user.isNewUser) { + history.push("phone", { name: user.name }, SLIDE); + return; + } + markLoggedIn(); + goHome(); + }} + /> + )} + phone={({ context, history }) => ( + history.back()} + // 단계를 바꾸지 않고 context만 갈아 끼운다(replace) + onChangePhone={(phone) => + history.replace("phone", (prev) => ({ ...prev, phone })) + } + onNext={(phone) => + history.push( + "terms", + { agreements: INITIAL_TERMS_AGREEMENTS, phone }, + SLIDE, + ) + } + /> + )} + terms={({ context, history }) => ( + history.back()} + // 단계를 바꾸지 않고 context만 갈아 끼운다(replace) — 약관 상세를 다녀와도 동의 상태가 남는다 + onChangeAgreements={(agreements) => + history.replace("terms", (prev) => ({ ...prev, agreements })) + } + onComplete={async () => { + await submitOnboarding({ + hasAgreedNotifications: context.agreements.notifications, + name: context.name, + phone: context.phone, + }); + markLoggedIn(); + // 가입을 마치면 약관 동의로 돌아올 일이 없어서 히스토리를 대체한다 + history.replace("complete", {}, SLIDE); + }} + onOpenDetail={(termsId) => + history.push("termsDetail", { termsId }, SLIDE) + } + /> + )} + termsDetail={({ context, history }) => ( + history.back()} + termsId={context.termsId} + /> + )} + /> + ); +} + +export default AuthSignupScreen; diff --git a/src/features/auth/KConnectCallbackScreen.tsx b/src/features/auth/KConnectCallbackScreen.tsx new file mode 100644 index 0000000..4329dd8 --- /dev/null +++ b/src/features/auth/KConnectCallbackScreen.tsx @@ -0,0 +1,65 @@ +import { useKConnectCallback } from "@billilge/oauth-kconnect/react"; +import { useEffect, useRef } from "react"; +import { useNavigate } from "react-router-dom"; + +import { exchangeKConnectCode } from "@/entities/auth/authApi"; +import { markLoggedIn } from "@/entities/auth/session"; +import { + AUTH_PATH, + LOGIN_FAILURE_MESSAGE, +} from "@/features/auth/constants/auth"; +import type { AuthLocationState } from "@/features/auth/utils/authLocationState"; + +// K-CONNECT가 인증을 마치고 되돌려 보내는 redirect URI 라우트(`/oauth/kconnect`) — 경로는 K-CONNECT에 등록한 +// 값이자 @billilge/oauth-kconnect가 정한 값이라 바꾸지 않는다. 보여 줄 UI는 없고 결과를 받아 처리한 뒤 이동한다: +// 성공 → 서버로 보내 서비스 로그인을 마친다. 신규 사용자는 로그인 퍼널의 연락처 단계로, 기존 사용자는 홈으로 간다 +// 취소·오류(국민대 학생이 아니어서 K-CONNECT가 거부한 경우 포함) → 로그인 화면에서 실패 토스트를 띄운다 +// 주소창에 `code`가 남지 않도록 어느 경우든 replace로 이동한다(oauth-kconnect README 안내). +function KConnectCallbackScreen() { + const navigate = useNavigate(); + const result = useKConnectCallback(); + // StrictMode가 효과를 두 번 돌려도 일회용 인증 code를 서버로 두 번 보내지 않는다 + const hasHandled = useRef(false); + + useEffect(() => { + if (hasHandled.current) { + return; + } + hasHandled.current = true; + + const goBackToLogin = () => + navigate(AUTH_PATH, { + replace: true, + state: { loginFailed: true } satisfies AuthLocationState, + }); + + if (result.type !== "success") { + if (result.type === "error") { + console.warn(LOGIN_FAILURE_MESSAGE, result.error); + } + goBackToLogin(); + return; + } + + exchangeKConnectCode(result) + .then((user) => { + if (user.isNewUser) { + navigate(AUTH_PATH, { + replace: true, + state: { kConnectName: user.name } satisfies AuthLocationState, + }); + return; + } + markLoggedIn(); + navigate("/", { replace: true }); + }) + .catch((error: unknown) => { + console.warn(LOGIN_FAILURE_MESSAGE, error); + goBackToLogin(); + }); + }, [result, navigate]); + + return
; +} + +export default KConnectCallbackScreen; diff --git a/src/features/auth/components/AuthBackButton.tsx b/src/features/auth/components/AuthBackButton.tsx new file mode 100644 index 0000000..d42dcfa --- /dev/null +++ b/src/features/auth/components/AuthBackButton.tsx @@ -0,0 +1,13 @@ +import { TopNavigationButton } from "@wanteddev/wds"; +import { IconChevronLeft } from "@wanteddev/wds-icon"; + +// 모든 단계의 헤더가 같은 뒤로가기 버튼을 쓴다 +function AuthBackButton({ onClick }: { onClick: () => void }) { + return ( + + + + ); +} + +export default AuthBackButton; diff --git a/src/features/auth/components/AuthBottomAction.tsx b/src/features/auth/components/AuthBottomAction.tsx new file mode 100644 index 0000000..ad486f1 --- /dev/null +++ b/src/features/auth/components/AuthBottomAction.tsx @@ -0,0 +1,33 @@ +import { ActionArea, ActionAreaButton } from "@wanteddev/wds"; + +// 하단 고정 버튼 영역 — WDS ActionAreaButton은 세로 padding 12px(48px)인데 Figma Main Action은 16px(56px)이라 맞춘다. +// ActionArea 아래 padding 20px + 14px = Figma Bottom Safe Area 34px(앱 WebView에서는 네이티브 세이프에어리어와 +// 겹쳐서 데스크톱 프레임에서만 남긴다). +function AuthBottomAction({ + label, + disabled = false, + onClick, + hasBackground = false, +}: { + label: string; + disabled?: boolean; + onClick: () => void; + hasBackground?: boolean; +}) { + return ( +
+ + + {label} + + +
+
+ ); +} + +export default AuthBottomAction; diff --git a/src/features/auth/components/AuthBrandHeader.tsx b/src/features/auth/components/AuthBrandHeader.tsx new file mode 100644 index 0000000..24275fe --- /dev/null +++ b/src/features/auth/components/AuthBrandHeader.tsx @@ -0,0 +1,14 @@ +import streamLogo from "@/assets/icons/auth/stream-logo.svg"; +import streamWordmark from "@/assets/icons/auth/stream-wordmark.svg"; + +// Figma: Login Brand Header (nodeId 3685:113326) — 심볼(48) + stream 워드마크, 간격 6. +function AuthBrandHeader() { + return ( +
+ + stream +
+ ); +} + +export default AuthBrandHeader; diff --git a/src/features/auth/components/AuthKConnectButton.tsx b/src/features/auth/components/AuthKConnectButton.tsx new file mode 100644 index 0000000..0f962f4 --- /dev/null +++ b/src/features/auth/components/AuthKConnectButton.tsx @@ -0,0 +1,32 @@ +import { Typography } from "@wanteddev/wds"; + +import kConnectLogo from "@/assets/icons/auth/k-connect-logo.svg"; + +interface AuthKConnectButtonProps { + isLoading: boolean; + onClick: () => void; +} + +// Figma: 로그인 Action Area > K-CONNECT 버튼 (nodeId 3147:135414) — Stream 로컬 버튼이다. +// K-CONNECT 브랜드색(#004F9F)이라 WDS ActionAreaButton의 primary 색으로는 만들 수 없고, 모서리 12px· +// 높이 57px(위아래 18px + 줄 높이 24px 안에 로고 28px)도 WDS 버튼 크기 스케일에 없다. +function AuthKConnectButton({ isLoading, onClick }: AuthKConnectButtonProps) { + return ( + + ); +} + +export default AuthKConnectButton; diff --git a/src/features/auth/components/AuthLoginCarousel.tsx b/src/features/auth/components/AuthLoginCarousel.tsx new file mode 100644 index 0000000..868f7a3 --- /dev/null +++ b/src/features/auth/components/AuthLoginCarousel.tsx @@ -0,0 +1,98 @@ +import { Typography } from "@wanteddev/wds"; +import { useRef, useState } from "react"; + +import illustrationCircle from "@/assets/icons/auth/illustration-circle.svg"; +import AuthPaginationDots from "@/features/auth/components/AuthPaginationDots"; +import { LOGIN_SLIDES } from "@/features/auth/constants/auth"; +import { usePrefersReducedMotion } from "@/hooks/usePrefersReducedMotion"; + +// Figma: Login Feature Content (nodeId 3658:113307) — 일러스트 320×240 + 문구, 아래에 페이지네이션 닷. +// 화면설계서 2번: 자동 전환은 없고 사용자가 스와이프로 다음·이전 슬라이드로 넘기며, 닷으로 현재 순서를 +// 보여 준다. 문구와 닷 사이는 Figma가 28px로 보이지만 Login Copy 프레임이 50px로 고정돼 문구(54px)가 4px 넘치는 +// 값이라, 실제 문구 끝에서 닷까지는 24px이다. 스와이프는 네이티브 가로 스크롤 + scroll-snap이라 따로 제스처 코드를 두지 않는다. +function AuthLoginCarousel() { + const scrollerRef = useRef(null); + const [page, setPage] = useState(0); + const shouldReduceMotion = usePrefersReducedMotion(); + + const handleScroll = () => { + const scroller = scrollerRef.current; + if (scroller) { + setPage(Math.round(scroller.scrollLeft / scroller.clientWidth)); + } + }; + + // 닷을 눌러도 해당 슬라이드로 넘어간다 + const goToPage = (nextPage: number) => { + const scroller = scrollerRef.current; + scroller?.scrollTo({ + behavior: shouldReduceMotion ? "auto" : "smooth", + left: nextPage * scroller.clientWidth, + }); + }; + + return ( +
+
+ {/* 일러스트 뒤 은은한 파란 빛(Figma Illustration Background Circle) — 223×138 타원 둘레로 번지는 + 623×538 이미지라 슬라이드 스크롤 영역 안에 두면 잘려서, 모든 슬라이드가 같은 자리를 쓰는 점을 이용해 + 스크롤 영역 밖에 한 번만 그린다. 화면 폭을 넘는 부분만 잘라 가로 스크롤이 생기지 않게 한다. */} +
+ +
+ +
+ {LOGIN_SLIDES.map((slide) => ( +
+ +
+ + {slide.title} + + + {slide.description} + +
+
+ ))} +
+
+ + +
+ ); +} + +export default AuthLoginCarousel; diff --git a/src/features/auth/components/AuthOnboardingHeading.tsx b/src/features/auth/components/AuthOnboardingHeading.tsx new file mode 100644 index 0000000..5df353a --- /dev/null +++ b/src/features/auth/components/AuthOnboardingHeading.tsx @@ -0,0 +1,35 @@ +import { Typography } from "@wanteddev/wds"; +import type { ReactNode } from "react"; + +interface AuthOnboardingHeadingProps { + /** "1 / 2"처럼 전체 단계 중 현재 단계(페이지 인디케이터, 화면설계서 2·5번) */ + step: string; + /** 줄바꿈이 있는 제목은 줄마다 요소로 넘긴다 */ + children: ReactNode; +} + +// Figma: Onboarding Copy (nodeId 3658:112629) — 단계 표시 + 제목(Heading 1/Bold 22px). +function AuthOnboardingHeading({ step, children }: AuthOnboardingHeadingProps) { + return ( +
+ + {step} + + + {children} + +
+ ); +} + +export default AuthOnboardingHeading; diff --git a/src/features/auth/components/AuthPaginationDots.tsx b/src/features/auth/components/AuthPaginationDots.tsx new file mode 100644 index 0000000..847d744 --- /dev/null +++ b/src/features/auth/components/AuthPaginationDots.tsx @@ -0,0 +1,37 @@ +interface AuthPaginationDotsProps { + totalPages: number; + /** 0부터 센다 */ + currentPage: number; + onSelect: (page: number) => void; +} + +// Figma: Pagination/Dots (nodeId 3658:113073) — Stream 로컬 컴포넌트다. 이름은 WDS `PaginationDots`와 +// 같지만 크기가 달라서 쓰지 않았다: Figma 닷은 지름 6px·간격 6px(중심 간격 12px)인데, WDS는 `small`이 +// 8px·간격 8px(16px), `medium`이 10px이고 닷 크기를 CSS 변수로만 바꿀 수 있어 내부 오버라이드가 된다. +// 현재 닷은 Label/Normal, 나머지는 같은 색의 16% 불투명도다. +function AuthPaginationDots({ + totalPages, + currentPage, + onSelect, +}: AuthPaginationDotsProps) { + return ( + + ); +} + +export default AuthPaginationDots; diff --git a/src/features/auth/components/AuthTermsAgreementRow.tsx b/src/features/auth/components/AuthTermsAgreementRow.tsx new file mode 100644 index 0000000..4f2fe70 --- /dev/null +++ b/src/features/auth/components/AuthTermsAgreementRow.tsx @@ -0,0 +1,68 @@ +import { Checkbox, Typography } from "@wanteddev/wds"; +import { useId } from "react"; + +import arrowRightIcon from "@/assets/icons/auth/arrow-right.svg"; + +interface AuthTermsAgreementRowProps { + label: string; + checked: boolean; + onCheckedChange: (checked: boolean) => void; + /** 있으면 오른쪽에 화살표가 붙고 눌러서 약관 상세로 간다 — "전체 동의하기" 행에는 없다 */ + onOpenDetail?: () => void; + /** 전체 동의 행은 글자가 SemiBold(Body 2/Bold), 개별 약관 행은 Regular다 */ + isBold?: boolean; +} + +// Figma: Control/Checkbox 행 — 전체 동의하기(nodeId 3658:112647), 개별 약관 행(3658:112650 외). +// WDS Checkbox는 label prop이 없고 ` + )} +
+ ); +} + +export default AuthTermsAgreementRow; diff --git a/src/features/auth/components/AuthTermsSection.tsx b/src/features/auth/components/AuthTermsSection.tsx new file mode 100644 index 0000000..29a8ee2 --- /dev/null +++ b/src/features/auth/components/AuthTermsSection.tsx @@ -0,0 +1,55 @@ +import { Typography } from "@wanteddev/wds"; + +interface AuthTermsSectionProps { + title: string; + body: string | string[]; + /** 약관 첫 조항만 제목과 본문 사이가 6px이고 나머지는 4px이다(Figma 그대로) */ + isFirst?: boolean; +} + +// Figma: Policy Section (nodeId 3738:73029 외) — 조항 제목(Body 1/Bold) + 본문(Label 1/Reading - Regular). +function AuthTermsSection({ + title, + body, + isFirst = false, +}: AuthTermsSectionProps) { + return ( +
+ + {title} + + {typeof body === "string" ? ( + + {body} + + ) : ( +
    + {body.map((item) => ( +
  • + + {item} + +
  • + ))} +
+ )} +
+ ); +} + +export default AuthTermsSection; diff --git a/src/features/auth/constants/auth.ts b/src/features/auth/constants/auth.ts new file mode 100644 index 0000000..7e4f37c --- /dev/null +++ b/src/features/auth/constants/auth.ts @@ -0,0 +1,161 @@ +import eventsIllustration from "@/assets/images/auth/login-events.png"; +import feedbacksIllustration from "@/assets/images/auth/login-feedbacks.png"; +import noticesIllustration from "@/assets/images/auth/login-notices.png"; + +// 로그인·온보딩 퍼널의 라우트 경로. 용어 사전(terminology.md)의 공통(`auth`) 아래에 둔다. +// 연락처·약관 동의 같은 단계는 이 경로 하나 안에서 useFunnel이 `?auth.step=`으로 나눈다. +export const AUTH_PATH = "/auth"; + +// K-CONNECT에 등록한 앱의 client_id — 환경 변수 `VITE_KCONNECT_CLIENT_ID`(.env.example 참고)로 받는다. +export const KCONNECT_CLIENT_ID: string = + import.meta.env.VITE_KCONNECT_CLIENT_ID ?? ""; + +// 연락처 — 010으로 시작하는 숫자 11자리를 모두 입력해야 `다음`이 켜진다(화면설계서 3번의 11자리에, 설정의 전화번호 +// 변경(#97)과 같은 010 고정 기준을 더했다 — 구 번호(011·016 등)로 가입하면 설정에서 같은 번호로 못 바꾸게 되기 때문이다). +export const PHONE_DIGIT_COUNT = 11; +export const PHONE_PREFIX = "010"; + +export const LOGIN_FAILURE_MESSAGE = "로그인에 실패했어요. 다시 시도해 주세요."; + +// Figma 로그인 1~3 — 슬라이드 순서와 문구(nodeId 3658:113051, 3658:113028, 3658:113074) +export const LOGIN_SLIDES = [ + { + description: "소융대 생활에 필요한 서비스를 한곳에 담았어요.", + id: "platform", + image: eventsIllustration, + title: "소융대 학생을 위한 생활 플랫폼", + }, + { + description: "공지와 행사 소식을 stream에서 확인해요.", + id: "news", + image: noticesIllustration, + title: "소프트웨어융합대학의 모든 소식", + }, + { + description: "열린피드백으로 의견을 전하고 답변을 받아보세요.", + id: "feedback", + image: feedbacksIllustration, + title: "내 의견을 소융대와 더 가까이", + }, +] as const; + +export type TermsId = "service" | "privacy" | "notifications"; + +export const TERMS_IDS: TermsId[] = ["service", "privacy", "notifications"]; + +// 약관별 동의 여부 — 온보딩 퍼널의 context로 단계 사이를 오간다. "전체 동의"는 따로 저장하지 않고 파생한다. +export type TermsAgreements = Record; + +export const INITIAL_TERMS_AGREEMENTS: TermsAgreements = { + notifications: false, + privacy: false, + service: false, +}; + +export function isTermsId(value: string | undefined): value is TermsId { + return TERMS_IDS.some((id) => id === value); +} + +interface TermsSection { + title: string; + /** 문단이면 문자열 하나, 항목 나열이면 문자열 배열(점 목록)이다 */ + body: string | string[]; +} + +interface Terms { + /** 동의 목록 행에 쓰는 라벨(앞에 [필수]/[선택]이 붙는다) */ + label: string; + /** 약관 상세 화면의 헤더 제목 */ + title: string; + isRequired: boolean; + sections: TermsSection[]; +} + +// Figma 온보딩의 약관 상세 3종(nodeId 3738:73022, 3738:73116, 3738:73209) 본문이다. 실제 약관 문구와 +// 게시 방식(서버·정적 문서)이 정해지면 교체한다. +export const TERMS: Record = { + notifications: { + isRequired: false, + label: "선택적 정보 알림 수신 동의", + sections: [ + { + body: "행사 신청 일정·결과, 대여 및 반납 안내, 사물함 이용 안내, 공지사항과 학생회 활동 소식 등 Stream에서 제공하는 정보 알림을 받을 수 있어요.", + title: "수신 동의 항목", + }, + { + body: "기기의 푸시 알림을 통해 안내해요. 알림 권한이 꺼져 있으면 메시지가 표시되지 않을 수 있으며, 기기 설정에서 권한을 변경할 수 있어요.", + title: "수신 방법", + }, + { + body: "정보 알림 수신 동의는 선택 사항이에요. 동의하지 않아도 Stream의 기본 서비스 이용에는 제한이 없어요.", + title: "동의 거부 권리", + }, + { + body: "언제든지 stream 알림 설정에서 알림을 끄거나, 기기 설정에서 stream의 알림 권한을 변경해 철회할 수 있어요.", + title: "동의 철회 방법", + }, + { + body: "알림 수신 동의 정보는 동의 철회 또는 회원 탈퇴 시까지 보유해요. 다만 법령에 따라 보관이 필요한 경우에는 해당 기간 동안 보관할 수 있어요.", + title: "보유 및 이용 기간", + }, + ], + title: "선택적 정보 알림 수신 동의", + }, + privacy: { + isRequired: true, + label: "개인정보 수집 및 이용 동의", + sections: [ + { + body: [ + "필수 항목: 학번, 이름, 이메일, 휴대전화번호", + "서비스 이용 중 생성: 사물함/물품 대여 이력, 행사 신청 내역, 피드백 작성 내용", + ], + title: "수집하는 개인정보 항목", + }, + { + body: "회원 본인 확인, 사물함·물품(빌릴게) 대여 신청 및 반납 관리, 행사 신청자 확인, 열린피드백 작성자 식별, 공지사항 및 서비스 안내 전달을 위해 이용해요.", + title: "수집 및 이용 목적", + }, + { + body: "회원 탈퇴 시 즉시 파기하는 것을 원칙으로 해요. 단, 대여 이력 등 분쟁 방지를 위해 필요한 항목은 관련 규정에 따라 최대 1년간 보관 후 파기해요.", + title: "보유 및 이용 기간", + }, + { + body: "수집한 개인정보는 원칙적으로 외부에 제공하지 않아요. AI 챗봇 응답 생성을 위해 외부 AI 사업자에게 질의 내용이 전달될 수 있으며, 이 경우 위탁받는 자와 위탁 업무 내용을 별도로 고지해요.", + title: "제3자 제공 및 처리위탁", + }, + { + body: "이용자는 개인정보 수집·이용에 동의하지 않을 권리가 있어요. 다만 필수 항목에 동의하지 않으면 Stream 회원가입 및 대여·신청 서비스 이용이 제한될 수 있어요.", + title: "동의 거부 권리 및 불이익", + }, + ], + title: "개인정보 수집 및 이용 동의", + }, + service: { + isRequired: true, + label: "서비스 이용약관 동의", + sections: [ + { + body: "본 약관은 소프트웨어융합대학 학생회가 운영하는 stream 서비스의 이용 조건과 절차, 이용자와 운영자의 권리·의무를 정하는 것을 목적으로 해요.", + title: "제1조 목적", + }, + { + body: "stream은 공지 확인, 행사 신청, 물품 대여, 사물함 신청·이용, 열린피드백 등 소프트웨어융합대학 학생회 관련 서비스를 제공해요.", + title: "제2조 서비스 내용", + }, + { + body: "이용자는 정확한 정보를 입력하고, 타인의 계정을 이용하거나 부정한 신청·대여를 해서는 안 돼요. 서비스 운영을 방해하는 행위도 제한돼요.", + title: "제3조 이용자 의무", + }, + { + body: "운영상 필요하거나 약관을 위반한 경우 서비스 이용이 제한될 수 있어요. 제한 사유와 기간은 가능한 범위에서 앱 또는 개별 안내로 알려드려요.", + title: "제4조 서비스 이용 제한", + }, + { + body: "약관이 변경되는 경우 적용일 전 앱에서 안내해요. 서비스 이용 중 문의사항은 Stream 내 문의 채널 또는 학생회에 알려 주세요.", + title: "제5조 약관 변경 및 문의", + }, + ], + title: "서비스 이용약관", + }, +}; diff --git a/src/features/auth/constants/authSignupFunnel.ts b/src/features/auth/constants/authSignupFunnel.ts new file mode 100644 index 0000000..78415bd --- /dev/null +++ b/src/features/auth/constants/authSignupFunnel.ts @@ -0,0 +1,65 @@ +import { + isTermsId, + TERMS_IDS, + type TermsAgreements, + type TermsId, +} from "@/features/auth/constants/auth"; + +// 가입(로그인 → 연락처 → 약관 동의 → 약관 상세 → 가입 완료) 퍼널의 단계와 단계별 context 타입이다. +// Figma: 스플래시 & 로그인 (nodeId 3658:112096), 온보딩 (3738:73115) / 화면설계서 3747:74283, 3747:74298. +export type AuthSignupSteps = { + login: Record; + phone: { name: string; phone?: string }; + terms: { name: string; phone: string; agreements: TermsAgreements }; + termsDetail: { + name: string; + phone: string; + agreements: TermsAgreements; + termsId: TermsId; + }; + complete: { name: string }; +}; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +function isTermsAgreements(value: unknown): value is TermsAgreements { + return ( + isRecord(value) && TERMS_IDS.every((id) => typeof value[id] === "boolean") + ); +} + +// 새로고침이나 주소 직접 입력처럼 앞 단계의 값이 없는 채로 중간 단계에 들어오면 guard가 걸러서 첫 단계(로그인)로 +// 돌려보낸다 — 이름이나 연락처 없이 약관 동의 화면이 열리는 일이 없다. +export const AUTH_SIGNUP_STEP_OPTIONS = { + complete: { + guard: (data: unknown): data is AuthSignupSteps["complete"] => + isRecord(data) && typeof data.name === "string", + }, + login: { + guard: (data: unknown): data is AuthSignupSteps["login"] => isRecord(data), + }, + phone: { + guard: (data: unknown): data is AuthSignupSteps["phone"] => + isRecord(data) && + typeof data.name === "string" && + (data.phone === undefined || typeof data.phone === "string"), + }, + terms: { + guard: (data: unknown): data is AuthSignupSteps["terms"] => + isRecord(data) && + typeof data.name === "string" && + typeof data.phone === "string" && + isTermsAgreements(data.agreements), + }, + termsDetail: { + guard: (data: unknown): data is AuthSignupSteps["termsDetail"] => + isRecord(data) && + typeof data.name === "string" && + typeof data.phone === "string" && + isTermsAgreements(data.agreements) && + typeof data.termsId === "string" && + isTermsId(data.termsId), + }, +}; diff --git a/src/features/auth/steps/AuthCompleteStep.tsx b/src/features/auth/steps/AuthCompleteStep.tsx new file mode 100644 index 0000000..50051fa --- /dev/null +++ b/src/features/auth/steps/AuthCompleteStep.tsx @@ -0,0 +1,19 @@ +import CompleteCheck from "@/components/ui/CompleteCheck"; +import ResultScreen from "@/components/ui/ResultScreen"; + +// 가입 완료 — 화면설계서 8번. 체크 로띠가 재생되고 `stream 시작하기`나 상단 닫기(X)는 홈으로 보낸다. +// 신청 완료 화면과 같은 뼈대라 공용 ResultScreen을 쓴다. +function AuthCompleteStep({ onStart }: { onStart: () => void }) { + return ( + } + illustrationGap={8} + onClose={onStart} + primaryAction={{ label: "stream 시작하기", onClick: onStart }} + title="가입을 완료했어요!" + /> + ); +} + +export default AuthCompleteStep; diff --git a/src/features/auth/steps/AuthLoginStep.tsx b/src/features/auth/steps/AuthLoginStep.tsx new file mode 100644 index 0000000..cb42315 --- /dev/null +++ b/src/features/auth/steps/AuthLoginStep.tsx @@ -0,0 +1,103 @@ +import { useKConnectLogin } from "@billilge/oauth-kconnect/react"; +import { useState } from "react"; + +import ScreenToast from "@/components/ui/ScreenToast"; +import { isMockKConnectKind, mockKConnectLogin } from "@/entities/auth/authApi"; +import type { KConnectUser } from "@/entities/auth/types"; +import AuthBrandHeader from "@/features/auth/components/AuthBrandHeader"; +import AuthKConnectButton from "@/features/auth/components/AuthKConnectButton"; +import AuthLoginCarousel from "@/features/auth/components/AuthLoginCarousel"; +import { + KCONNECT_CLIENT_ID, + LOGIN_FAILURE_MESSAGE, +} from "@/features/auth/constants/auth"; +import { isInAppShell } from "@/lib/bridge/bridge"; + +// 로그인 — 화면설계서 2·3번. `K-CONNECT로 시작하기`를 누르면 K-CONNECT 인증 사이트로 페이지가 이동하고(@billilge/oauth-kconnect), +// 인증을 마치면 `/oauth/kconnect`(KConnectCallbackScreen)가 결과를 받아 신규 사용자는 연락처 단계로, 기존 사용자는 홈으로 보낸다. +// 취소·오류·네트워크 오류로 돌아오면 이 화면에서 토스트로 알린다. +function AuthLoginStep({ + hasFailedLogin, + onMockLoggedIn, +}: { + hasFailedLogin: boolean; + /** 개발 서버 전용 우회(`?mockKConnect=`)로 인증된 것으로 칠 때 */ + onMockLoggedIn: (user: KConnectUser) => void; +}) { + const { login } = useKConnectLogin({ clientId: KCONNECT_CLIENT_ID }); + const [isLoggingIn, setIsLoggingIn] = useState(false); + const [toastOpen, setToastOpen] = useState(hasFailedLogin); + // 실패가 연달아 나도 스크린리더가 토스트를 다시 안내하도록 실패마다 ScreenToast를 새로 마운트한다 + const [toastToken, setToastToken] = useState(0); + + const showFailure = () => { + setToastOpen(true); + setToastToken((token) => token + 1); + }; + + const handleLogin = async () => { + // 개발 서버에서만: 주소에 `?mockKConnect=new|existing`가 있으면 K-CONNECT 인증 사이트로 가지 않고 인증된 것으로 친다. + // `import.meta.env.DEV`가 상수로 치환되어 배포 빌드에서는 이 분기가 통째로 빠진다. + if (import.meta.env.DEV) { + const mockKind = new URLSearchParams(window.location.search).get( + "mockKConnect", + ); + if (isMockKConnectKind(mockKind)) { + setIsLoggingIn(true); + onMockLoggedIn(await mockKConnectLogin(mockKind)); + setIsLoggingIn(false); + return; + } + } + if (!KCONNECT_CLIENT_ID) { + console.warn( + "VITE_KCONNECT_CLIENT_ID가 없어서 K-CONNECT 로그인을 시작할 수 없어요(.env.example 참고).", + ); + showFailure(); + return; + } + setIsLoggingIn(true); + try { + // 성공하면 페이지가 K-CONNECT로 넘어가서 이 화면으로 돌아오지 않는다 — 그동안 버튼은 눌린 상태로 둔다 + await login(); + } catch (error) { + console.warn(LOGIN_FAILURE_MESSAGE, error); + setIsLoggingIn(false); + showFailure(); + } + }; + + // 앱 WebView에서는 Figma 그대로 위에서 72px에 시작하고 버튼을 바닥에 붙인다 — Figma 프레임의 상태 표시줄 + // (54px)은 앱 셸 몫이라 웹 영역은 그 아래에서 시작한다. 브라우저는 창 전체가 웹 영역이라 같은 값을 쓰면 + // 본문이 위로 치우쳐 보여서, 버튼 위 영역의 세로 가운데에 놓는다(auto 마진이 남는 공간을 위아래로 나눈다). + const isInApp = isInAppShell(); + + return ( +
+
+ + +
+ +
+
+ +
+
+
+ + +
+ ); +} + +export default AuthLoginStep; diff --git a/src/features/auth/steps/AuthPhoneStep.tsx b/src/features/auth/steps/AuthPhoneStep.tsx new file mode 100644 index 0000000..93b8015 --- /dev/null +++ b/src/features/auth/steps/AuthPhoneStep.tsx @@ -0,0 +1,82 @@ +import { TextField, Typography } from "@wanteddev/wds"; +import { useState } from "react"; + +import ScreenHeader from "@/components/ui/ScreenHeader"; +import { useScreenHeader } from "@/components/ui/useScreenHeader"; +import AuthBackButton from "@/features/auth/components/AuthBackButton"; +import AuthBottomAction from "@/features/auth/components/AuthBottomAction"; +import AuthOnboardingHeading from "@/features/auth/components/AuthOnboardingHeading"; +import { + formatPhoneNumber, + isValidPhoneDigits, + toPhoneDigits, +} from "@/features/auth/utils/formatPhoneNumber"; + +// 온보딩 1/2 연락처 — 화면설계서(3747:74298) 1~3번. 인삿말에 K-CONNECT가 알려 준 이름을 넣고, 숫자만 입력해도 +// 하이픈이 자동으로 들어간다. 010으로 시작하는 11자리를 모두 입력해야 `다음`이 켜진다. +function AuthPhoneStep({ + name, + initialPhone, + onBack, + onChangePhone, + onNext, +}: { + name: string; + initialPhone: string; + onBack: () => void; + /** 입력할 때마다 퍼널 context에 반영한다 — 약관 동의에서 뒤로 돌아와도 번호가 남는다 */ + onChangePhone: (phone: string) => void; + onNext: (phone: string) => void; +}) { + const [digits, setDigits] = useState(() => toPhoneDigits(initialPhone)); + + useScreenHeader( + } + variant="normal" + />, + ); + + return ( +
+
+ + {name} 님, 반가워요! + 연락처를 알려 주세요 + +
+ + 행사 신청을 위해 필요한 정보예요. + + { + const nextDigits = toPhoneDigits(event.target.value); + setDigits(nextDigits); + onChangePhone(nextDigits); + }} + placeholder="ex) 010-1234-5677" + type="tel" + value={formatPhoneNumber(digits)} + width="100%" + /> +
+
+ + onNext(digits)} + /> +
+ ); +} + +export default AuthPhoneStep; diff --git a/src/features/auth/steps/AuthTermsDetailStep.tsx b/src/features/auth/steps/AuthTermsDetailStep.tsx new file mode 100644 index 0000000..e0de493 --- /dev/null +++ b/src/features/auth/steps/AuthTermsDetailStep.tsx @@ -0,0 +1,50 @@ +import { Divider } from "@wanteddev/wds"; +import { Fragment } from "react"; + +import ScreenHeader from "@/components/ui/ScreenHeader"; +import { useScreenHeader } from "@/components/ui/useScreenHeader"; +import AuthBackButton from "@/features/auth/components/AuthBackButton"; +import AuthBottomAction from "@/features/auth/components/AuthBottomAction"; +import AuthTermsSection from "@/features/auth/components/AuthTermsSection"; +import { TERMS, type TermsId } from "@/features/auth/constants/auth"; + +// 약관 상세 — 화면설계서 7번. 하단 `확인했어요`나 뒤로가기는 약관 동의로 돌아가기만 한다 — 상세를 보거나 +// 확인했다는 것만으로는 동의 상태가 바뀌지 않는다. +function AuthTermsDetailStep({ + termsId, + onBack, +}: { + termsId: TermsId; + onBack: () => void; +}) { + const terms = TERMS[termsId]; + + useScreenHeader( + } + title={terms.title} + variant="normal" + />, + ); + + return ( +
+
+ {terms.sections.map((section, index) => ( + + {index > 0 && } + + + ))} +
+ + +
+ ); +} + +export default AuthTermsDetailStep; diff --git a/src/features/auth/steps/AuthTermsStep.tsx b/src/features/auth/steps/AuthTermsStep.tsx new file mode 100644 index 0000000..5ecd8d6 --- /dev/null +++ b/src/features/auth/steps/AuthTermsStep.tsx @@ -0,0 +1,100 @@ +import { Divider } from "@wanteddev/wds"; +import { useState } from "react"; + +import ScreenHeader from "@/components/ui/ScreenHeader"; +import { useScreenHeader } from "@/components/ui/useScreenHeader"; +import AuthBackButton from "@/features/auth/components/AuthBackButton"; +import AuthBottomAction from "@/features/auth/components/AuthBottomAction"; +import AuthOnboardingHeading from "@/features/auth/components/AuthOnboardingHeading"; +import AuthTermsAgreementRow from "@/features/auth/components/AuthTermsAgreementRow"; +import { + TERMS, + TERMS_IDS, + type TermsAgreements, + type TermsId, +} from "@/features/auth/constants/auth"; + +// 온보딩 2/2 약관 동의 — 화면설계서 4~7번. 필수 약관 2개(서비스 이용약관, 개인정보 수집·이용)에 모두 동의해야 `다음`이 +// 켜지고, 선택 약관(알림 수신)은 미동의여도 진행한다. 각 행 오른쪽 화살표는 약관 상세로 간다. +// 전체 동의 ↔ 개별 항목은 연동된다 — 전체를 누르면 세 항목을 같은 값으로 맞추고, 전체는 따로 저장하지 않고 +// 세 항목이 모두 켜졌는지로 파생한다. +function AuthTermsStep({ + agreements, + onBack, + onChangeAgreements, + onOpenDetail, + onComplete, +}: { + agreements: TermsAgreements; + onBack: () => void; + onChangeAgreements: (next: TermsAgreements) => void; + onOpenDetail: (termsId: TermsId) => void; + onComplete: () => Promise; +}) { + const [isSubmitting, setIsSubmitting] = useState(false); + const isAllAgreed = TERMS_IDS.every((id) => agreements[id]); + const isRequiredAgreed = agreements.service && agreements.privacy; + + useScreenHeader( + } + variant="normal" + />, + ); + + const handleComplete = async () => { + setIsSubmitting(true); + try { + await onComplete(); + } catch (error) { + console.warn("온보딩 정보를 저장하지 못했어요.", error); + } finally { + setIsSubmitting(false); + } + }; + + return ( +
+
+ + 서비스 이용을 위해 + 약관 동의가 필요해요 + +
+ + onChangeAgreements({ + notifications: checked, + privacy: checked, + service: checked, + }) + } + /> + + {TERMS_IDS.map((id) => ( + + onChangeAgreements({ ...agreements, [id]: checked }) + } + onOpenDetail={() => onOpenDetail(id)} + /> + ))} +
+
+ + +
+ ); +} + +export default AuthTermsStep; diff --git a/src/features/auth/utils/authLocationState.ts b/src/features/auth/utils/authLocationState.ts new file mode 100644 index 0000000..81e61f0 --- /dev/null +++ b/src/features/auth/utils/authLocationState.ts @@ -0,0 +1,21 @@ +// K-CONNECT 콜백 화면(`/oauth/kconnect`)이 로그인 퍼널(`/auth`)로 결과를 넘기는 navigate state다. +// 콜백 주소에는 인증 `code`가 남으면 안 되어서 replace로 퍼널로 이동하면서 필요한 값만 state로 건넨다. +export interface AuthLocationState { + /** K-CONNECT가 알려 준 이름 — 있으면 로그인을 건너뛰고 연락처 단계에서 시작한다(신규 사용자) */ + kConnectName?: string; + /** 로그인이 실패·취소돼 돌아온 경우 — 로그인 화면에서 실패 토스트를 띄운다 */ + loginFailed?: boolean; +} + +export function readAuthLocationState(state: unknown): AuthLocationState { + if (typeof state !== "object" || state === null) { + return {}; + } + return { + kConnectName: + "kConnectName" in state && typeof state.kConnectName === "string" + ? state.kConnectName + : undefined, + loginFailed: "loginFailed" in state && state.loginFailed === true, + }; +} diff --git a/src/features/auth/utils/formatPhoneNumber.ts b/src/features/auth/utils/formatPhoneNumber.ts new file mode 100644 index 0000000..3bc62bc --- /dev/null +++ b/src/features/auth/utils/formatPhoneNumber.ts @@ -0,0 +1,25 @@ +import { + PHONE_DIGIT_COUNT, + PHONE_PREFIX, +} from "@/features/auth/constants/auth"; + +/** 입력값에서 숫자만 꺼낸다(최대 11자리). */ +export function toPhoneDigits(value: string): string { + return value.replace(/\D/g, "").slice(0, PHONE_DIGIT_COUNT); +} + +/** 010으로 시작하는 숫자 11자리인지(하이픈 없는 값 기준) */ +export function isValidPhoneDigits(digits: string): boolean { + return digits.length === PHONE_DIGIT_COUNT && digits.startsWith(PHONE_PREFIX); +} + +// 화면설계서 1번: 숫자만 입력해도 하이픈을 자동으로 넣는다(01012345678 → 010-1234-5678). +export function formatPhoneNumber(digits: string): string { + 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)}`; +} diff --git a/src/index.css b/src/index.css index 2af850f..5fae147 100644 --- a/src/index.css +++ b/src/index.css @@ -46,6 +46,10 @@ /* 알림 목록에서 아직 안 읽은 알림 항목의 배경(Figma "Atomic/Blue/99", 알림 nodeId 3595:107106) */ --color-notification-unread: var(--atomic-blue-99); + /* 로그인 화면 `K-CONNECT로 시작하기` 버튼 배경(Figma 로그인 nodeId 3147:135414). K-CONNECT 브랜드색이라 + Figma에서도 변수가 아니라 raw #004f9f로 그려져 있고 대응하는 WDS 시맨틱 토큰이 없다. */ + --color-k-connect: #004f9f; + /* 사물함 구역 선택의 마감 구역 배경(Figma "Cool Neutral/97") — 회색 계열이지만 Background/Normal/Alternative(#f7f7f8)보다 한 단계 진해서 잠긴 걸 드러낸다 */ --color-cool-neutral-97: var(--atomic-coolNeutral-97); diff --git a/src/lib/useFunnel.ts b/src/lib/useFunnel.ts new file mode 100644 index 0000000..918970f --- /dev/null +++ b/src/lib/useFunnel.ts @@ -0,0 +1,127 @@ +import { createUseFunnel } from "@use-funnel/react-router-dom"; +import { useMemo } from "react"; +import { useLocation, useNavigate, useSearchParams } from "react-router-dom"; + +interface FunnelRouteOption { + preventScrollReset?: boolean; + flushSync?: boolean; + /** 단계 전환에 슬라이드(View Transition)를 켠다 — 화면 이동의 `viewTransition: true`와 같다 */ + viewTransition?: boolean; +} + +// `@use-funnel/react-router-dom`이 기본으로 내보내는 useFunnel을 그대로 쓰지 않고 같은 라우터 어댑터를 직접 만든다. +// 기본 어댑터는 View Transition 옵션 이름을 `unstable_viewTransition`으로 넘기는데, React Router 7부터는 +// 이 이름이 `viewTransition`으로 바뀌어서(7.18.3 소스에 `unstable_viewTransition`이 없다) 옵션이 무시되고 단계가 +// 즉시 바뀐다. 이 파일은 어댑터(createUseFunnel에 넘기는 라우터 함수)를 그대로 옮기고 옵션 이름만 바꿨다 — +// 단계는 `?{id}.step=`, context와 단계 기록은 history.state의 `{id}.context`·`{id}.histories`에 저장된다. +// 라이브러리의 "라우터 직접 만들기" 가이드가 안내하는 방식이다. +export const useFunnel = createUseFunnel( + ({ id, initialState }) => { + const location = useLocation(); + const [searchParams, setSearchParams] = useSearchParams(); + const navigate = useNavigate(); + + const stepName = `${id}.step`; + const contextName = `${id}.context`; + const historiesName = `${id}.histories`; + + const currentStep = searchParams.get(stepName); + const currentContext = location.state?.[contextName]; + const currentState = useMemo( + () => + currentStep != null && currentContext != null + ? { context: currentContext, step: currentStep } + : initialState, + [currentStep, currentContext, initialState], + ); + + const history = useMemo(() => { + const histories = location.state?.[historiesName]; + if (Array.isArray(histories) && histories.length > 0) { + return histories; + } + return [currentState]; + }, [location.state, currentState, historiesName]); + const currentIndex = history.length - 1; + + return useMemo( + () => ({ + cleanup() { + const newLocationState = { ...location.state }; + const currentSearchParams = new URLSearchParams( + window.location.search, + ); + if ( + !currentSearchParams.has(stepName) || + !(contextName in newLocationState) || + !(historiesName in newLocationState) + ) { + return; + } + delete newLocationState[contextName]; + delete newLocationState[historiesName]; + setSearchParams( + (prev) => { + prev.delete(stepName); + return prev; + }, + { replace: true, state: newLocationState }, + ); + }, + currentIndex, + go(index: number) { + navigate(index); + }, + history, + push(state, { preventScrollReset, flushSync, viewTransition } = {}) { + setSearchParams( + (prev) => { + prev.set(stepName, state.step); + return prev; + }, + { + flushSync, + preventScrollReset, + replace: false, + state: { + ...location.state, + [contextName]: state.context, + [historiesName]: [...history, state], + }, + viewTransition, + }, + ); + }, + replace(state, { preventScrollReset, flushSync, viewTransition } = {}) { + setSearchParams( + (prev) => { + prev.set(stepName, state.step); + return prev; + }, + { + flushSync, + preventScrollReset, + replace: true, + state: { + ...location.state, + [contextName]: state.context, + [historiesName]: [...history.slice(0, currentIndex), state], + }, + viewTransition, + }, + ); + }, + }), + [ + contextName, + currentIndex, + history, + historiesName, + location.state, + navigate, + setSearchParams, + stepName, + ], + ); + }, +);