diff --git a/docs/conventions/wds-component-usage.md b/docs/conventions/wds-component-usage.md index b250e9a..5fb7a46 100644 --- a/docs/conventions/wds-component-usage.md +++ b/docs/conventions/wds-component-usage.md @@ -746,3 +746,53 @@ Figma 프레임의 상태 표시줄(54px)과 홈 바(34px)는 앱 셸 몫이라 ### 로그인·가입은 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`로 직접 만들고 옵션 이름만 바꿨다(라이브러리의 "라우터 직접 만들기" 가이드 방식) — 라이브러리를 올릴 때 이 이름이 고쳐졌는지 확인하고 고쳐졌다면 이 파일을 지운다. + +## 학생회비 계좌 송금 플로우(`3562:162833` 외 5종) 구현 중 확정된 매핑 + +| Figma 인스턴스 | 코드 | 확정 근거 | +|---|---|---| +| `Textinput/Textfield` | `TextField`(`@wanteddev/wds`) | 메인 컴포넌트 Node ID `445:8591` + [공식 문서](https://montage.wanted.co.kr/docs/components/selection-and-input/text-field/design). 남은 학기 수 입력칸(`3562:162844`)이 335×48·radius 12px로 실측 일치 | +| `Icon/Normal/Upload` | `IconUpload`(`@wanteddev/wds-icon`) | 메인 컴포넌트 Node ID `3716:22984`("내용을 업로드할 때 사용합니다") | +| `Icon/Normal/Trash` | `IconTrash`(`@wanteddev/wds-icon`) | 메인 컴포넌트 Node ID `3562:162692`("내용을 삭제할 때 사용합니다") | +| `Modal`(`3562:162972`) | 공용 `ConfirmModal` | 311×190·radius 24px·버튼 2개로 기존 `Modal`(`995:34701`)과 같은 인스턴스다 | + +### `Image Upload Button`은 WDS가 아니다 + +업로드 버튼(`3716:23609`, 335×46·radius 10px·1px `Line/Normal/Neutral` 테두리 + primary 라벨)은 +`search_design_system`에 같은 모양이 없다. 안쪽 아이콘만 WDS `Icon/Normal/Upload`고 껍데기는 Stream +로컬이다 — WDS `Button`의 outlined는 radius·높이가 달라서 쓰지 않았다. + +### 파일 행 아이콘(`fa7-solid:image`)도 WDS가 아니다 + +업로드한 파일 행의 28px 이미지 아이콘(`3562:162924`)은 이름 그대로 FontAwesome 글리프다. WDS +`IconImage`는 외곽선 스타일이라 **이름은 비슷해도 모양이 달라서** SVG를 받아 +`src/assets/icons/fee/image-file.svg`로 커밋했다. + +### 48px 일러스트는 `export`로 받고 캔버스 배경을 걷어내야 한다 + +`Won Icon`(`3562:163032`)·`Send Fast Icon`(`3562:162980`)은 `svgAssets`가 레이어별로 쪼개져 나와 +`download_assets`의 `export`(format `svg`)를 써야 한다. 그때 붙는 ``와 캔버스 +전체를 덮는 `` 조상 path들을 지우고 아이콘 그룹만 남겼다(사물함 유의사항 일러스트와 같은 처리). + +### 송금 확인 화면의 Sub Action은 `ActionArea variant="strong"`이다 + +주 버튼 아래 텍스트 버튼을 세로로 쌓는 배치(`3562:162974`)는 WDS가 `variant="strong"`일 때만 +`flex-direction: column` + `gap: 8px`가 된다(기본값은 가로 12px). 다만 `ActionAreaButton variant="sub"`가 +세로 margin 8px을 따로 갖고 있어 그대로 두면 간격이 16px이 되므로 `sx`로 `marginBlock: 0`을 준다. +높이도 Figma가 44px인데 WDS는 28px이라 `paddingBlock: "12px"`로 보정한다. + +### 카드 테두리를 `border`로 그리면 안쪽 폭이 2px 깎인다 + +업로드한 이미지 카드(`3562:162918`)는 Figma에서 335×104 안에 303px 행이 들어간다. CSS `border`는 +`box-sizing: border-box` 때문에 가로·세로를 각각 2px씩 먹어서 333/102가 된다 — `outline-1` +`-outline-offset-1`로 그려야 Figma 수치와 맞는다. + +### 계좌 송금은 useFunnel 퍼널 하나(`FeeTransferScreen`)다 + +남은 학기 수 → 학점이수현황 캡처 → 토스 이동 → 송금 확인 → 확인요청 완료를 `@use-funnel/react-router-dom`의 `useFunnel`로 묶었다(라우트 `/fee/transfer`, 단계는 `?fee.step=`). 로그인·가입 퍼널(`AuthSignupScreen`)과 같은 구성이다 — 퍼널 파일(`FeeTransferScreen.tsx`)은 단계 전환만 맡고, 단계 화면은 `features/fee/steps/`에 파일 하나씩(`FeeTransferSemesterStep`, `FeeTransferTranscriptStep`, `FeeTransferTossStep`, `FeeTransferConfirmStep`, `FeeTransferCompleteStep`) 두며 전환은 콜백 prop으로 받는다. 단계·context 타입과 `guard`는 `constants/feeTransferFunnel.ts`에 있고, 단계 화면이 공유하는 뼈대는 `components/FeeTransferStepLayout.tsx`다(뒤로가기는 `onBack` prop으로 받아서 첫 단계는 플로우 밖으로, 나머지는 이전 단계로 간다). + +처음에는 단계마다 라우트를 따로 뒀는데(`/fee/transfer/semester` 등 5개), 그러면 단계 사이에 값을 navigate state로 넘겨야 해서 남은 학기 수가 캡처 단계에서 읽히지 않고 고른 파일도 다음 단계로 넘어가지 않았다. 퍼널 context로 바꾸면서 두 값이 플로우 끝까지 이어지고, 토스 단계에서 뒤로 돌아와도 입력이 남는다. 학기 수 입력·파일 선택은 단계를 바꾸지 않고 `history.replace`로 context만 갈아 끼운다(히스토리가 늘지 않음). 앞 단계 값 없이 중간 단계로 들어오면(새로고침·주소 직접 입력) `guard`가 걸러서 첫 단계로 돌려보낸다. + +캡처 이미지는 `File`을 context에 그대로 담는다 — `File`은 구조화 복제가 되는 값이라 history.state에 실린다. 다만 history.state는 브라우저마다 크기 상한이 있어서(Firefox 16MiB) 업로드 API가 붙으면 파일 대신 서버가 준 식별자를 담는 쪽으로 바꾸는 게 안전하다. `File`이 history.state에서 되살아나지 못하는 경우도 있어 `guard`가 `instanceof File`로 확인하고, 아니면 첫 단계로 돌려보낸다. + +토스 이동 안내/실패(`3562:163025` / `3562:163046`)와 캡처 업로드 전/후(`3562:162845` / `3562:162891`)는 각각 **한 단계 안의 state**다. Figma가 프레임을 나눠 뒀지만 퍼널 단계로는 올리지 않았다 — 뒤로가기로 돌아갈 지점이 아니기 때문이다. diff --git a/src/app/router.tsx b/src/app/router.tsx index 943cc39..d9bdfb1 100644 --- a/src/app/router.tsx +++ b/src/app/router.tsx @@ -21,6 +21,7 @@ import EventsApplicationScreen from "@/features/events/EventsApplicationScreen"; import EventsDetailScreen from "@/features/events/EventsDetailScreen"; import EventsListScreen from "@/features/events/EventsListScreen"; import FeePaymentsScreen from "@/features/fee/FeePaymentsScreen"; +import FeeTransferScreen from "@/features/fee/FeeTransferScreen"; import FeedbacksDetailScreen from "@/features/feedbacks/FeedbacksDetailScreen"; import FeedbacksListScreen from "@/features/feedbacks/FeedbacksListScreen"; import FeedbacksMineDetailScreen from "@/features/feedbacks/FeedbacksMineDetailScreen"; @@ -199,6 +200,17 @@ const routes = [ // 시트만 있는 화면이라 뒤에 Bottom Nav가 비쳐도 Figma(홈 위에 뜨는 시트)와 같다 path: "/lockers/apply", }, + { + element: , + // 학생회비 계좌 송금 퍼널 하나가 모든 단계(남은 학기 수, 학점이수현황 캡처, 토스 이동, + // 송금 확인, 확인요청 완료)를 그린다 — 단계는 `?fee.step=`으로 나뉜다. + // 전부 하단 고정 버튼(Action Area)이 있는 흰 배경 화면이라 Bottom Nav가 없다. + handle: { + background: "normal", + hasBottomNav: false, + } satisfies ScreenRouteHandle, + path: "/fee/transfer", + }, { element: , // 검색 화면은 Figma에 Bottom Nav가 없고(뒤로가기로 진입한 화면에 복귀) 배경이 흰 면이다 diff --git a/src/assets/icons/fee/image-file.svg b/src/assets/icons/fee/image-file.svg new file mode 100644 index 0000000..b6ee410 --- /dev/null +++ b/src/assets/icons/fee/image-file.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/assets/images/fee/transcript-example.png b/src/assets/images/fee/transcript-example.png new file mode 100644 index 0000000..ebf6209 Binary files /dev/null and b/src/assets/images/fee/transcript-example.png differ diff --git a/src/assets/lottie/fee/send-fast.json b/src/assets/lottie/fee/send-fast.json new file mode 100644 index 0000000..4905535 --- /dev/null +++ b/src/assets/lottie/fee/send-fast.json @@ -0,0 +1 @@ +{"nm":"keyline-icons:send-fast-duotone","ddd":0,"h":48,"w":48,"meta":{"g":"LottieFiles Figma (Figma Motion)"},"layers":[{"ty":4,"nm":"Send Fast Icon","sr":1,"st":0,"op":121,"ip":0,"hd":false,"ln":"Send Fast Icon 0","ddd":0,"bm":0,"hasMask":false,"ao":0,"ks":{"a":{"a":0,"k":[17,17]},"s":{"a":1,"k":[{"o":{"x":0.22,"y":0.85},"i":{"x":0.35,"y":1},"s":[82,82],"t":0},{"o":{"x":0.65,"y":0},"i":{"x":0.35,"y":1},"s":[103,103],"t":23},{"o":{"x":0.33,"y":1},"i":{"x":0.68,"y":1},"s":[98,98],"t":36},{"s":[100,100],"t":49}]},"sk":{"a":0,"k":0},"p":{"a":1,"k":[{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[21,27],"t":0},{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[21.77,26.26],"t":1},{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[22.48,25.59],"t":2},{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[23.1,24.99],"t":3},{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[23.62,24.49],"t":4},{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[24.05,24.08],"t":5},{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[24.4,23.74],"t":6},{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[24.68,23.47],"t":7},{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[24.9,23.26],"t":8},{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[25.08,23.09],"t":9},{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[25.22,22.96],"t":10},{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[25.33,22.85],"t":11},{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[25.42,22.77],"t":12},{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[25.49,22.7],"t":13},{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[25.54,22.65],"t":14},{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[25.59,22.61],"t":15},{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[25.62,22.58],"t":16},{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[25.65,22.55],"t":17},{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[25.67,22.53],"t":18},{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[25.68,22.52],"t":19},{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[25.69,22.51],"t":20},{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[25.7,22.5],"t":21},{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[25.7,22.5],"t":22},{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[25.7,22.5],"t":23},{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[25.69,22.51],"t":24},{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[25.66,22.53],"t":25},{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[25.6,22.57],"t":26},{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[25.5,22.64],"t":27},{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[25.34,22.76],"t":28},{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[25.08,22.96],"t":29},{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[24.76,23.19],"t":30},{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[24.52,23.36],"t":31},{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[24.37,23.47],"t":32},{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[24.28,23.54],"t":33},{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[24.23,23.58],"t":34},{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[24.21,23.59],"t":35},{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[24.2,23.6],"t":36},{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[24.37,23.47],"t":37},{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[24.51,23.37],"t":38},{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[24.63,23.28],"t":39},{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[24.73,23.2],"t":40},{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[24.81,23.15],"t":41},{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[24.87,23.1],"t":42},{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[24.91,23.06],"t":43},{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[24.95,23.04],"t":44},{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[24.97,23.02],"t":45},{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[24.99,23.01],"t":46},{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[25,23],"t":47},{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[25,23],"t":48},{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[25,23],"t":49},{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[25,23],"t":50},{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[25,23],"t":51},{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[25,23],"t":52},{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[25,23],"t":53},{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[25,23],"t":54},{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[25,23],"t":55},{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[25,23],"t":56},{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[25,23],"t":57},{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[25,23],"t":58},{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[25,23],"t":59},{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[25,23],"t":60},{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[25,23],"t":61},{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[25,23],"t":62},{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[25,23],"t":63},{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[25,23],"t":64},{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[25,23],"t":65},{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[25,23],"t":66},{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[25,23],"t":67},{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[25,23],"t":68},{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[25,23],"t":69},{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[25,23],"t":70},{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[25,23],"t":71},{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[25,23],"t":72},{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[25,23],"t":73},{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[25,23],"t":74},{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[25,23],"t":75},{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[25,23],"t":76},{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[25,23],"t":77},{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[25,23],"t":78},{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[25,23],"t":79},{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[25,23],"t":80},{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[25,23],"t":81},{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[25,23],"t":82},{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[25,23],"t":83},{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[25,23],"t":84},{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[25,23],"t":85},{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[25,23],"t":86},{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[25,23],"t":87},{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[25,23],"t":88},{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[25,23],"t":89},{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[25,23],"t":90},{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[25,23],"t":91},{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[25,23],"t":92},{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[25,23],"t":93},{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[25,23],"t":94},{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[25,23],"t":95},{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[25,23],"t":96},{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[25,23],"t":97},{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[25,23],"t":98},{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[25,23],"t":99},{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[25,23],"t":100},{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[25,23],"t":101},{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[25,23],"t":102},{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[25,23],"t":103},{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[25,23],"t":104},{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[25,23],"t":105},{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[25,23],"t":106},{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[25,23],"t":107},{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[25,23],"t":108},{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[25,23],"t":109},{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[25,23],"t":110},{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[25,23],"t":111},{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[25,23],"t":112},{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[25,23],"t":113},{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[25,23],"t":114},{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[25,23],"t":115},{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[25,23],"t":116},{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[25,23],"t":117},{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[25,23],"t":118},{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[25,23],"t":119},{"s":[25,23],"t":120}]},"r":{"a":1,"k":[{"o":{"x":0.22,"y":0.85},"i":{"x":0.35,"y":1},"s":[6],"t":0},{"o":{"x":0.65,"y":0},"i":{"x":0.35,"y":1},"s":[-2.5],"t":23},{"o":{"x":0.33,"y":1},"i":{"x":0.68,"y":1},"s":[1],"t":36},{"s":[0],"t":49}]},"sa":{"a":0,"k":0},"o":{"a":1,"k":[{"o":{"x":0.2,"y":0},"i":{"x":0.2,"y":1},"s":[0],"t":0},{"s":[100],"t":22}]}},"shapes":[],"ind":1},{"ty":4,"nm":"Transfer Illustration Path 3","sr":1,"st":0,"op":121,"ip":0,"hd":false,"ln":"Transfer Illustration Path 3 0","ddd":0,"bm":0,"hasMask":false,"ao":0,"ks":{"a":{"a":0,"k":[7.69,7.69]},"s":{"a":0,"k":[100,100]},"sk":{"a":0,"k":0},"p":{"a":0,"k":[7.69,26.31]},"r":{"a":0,"k":0},"sa":{"a":0,"k":0},"o":{"a":1,"k":[{"o":{"x":0.2,"y":0},"i":{"x":0.2,"y":1},"s":[0],"t":0},{"s":[100],"t":24}]}},"shapes":[{"ty":"sh","bm":0,"hd":false,"nm":"","d":1,"ks":{"a":0,"k":{"c":false,"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[0,15.38],[7.29,8.1]]}}},{"ty":"sh","bm":0,"hd":false,"nm":"","d":1,"ks":{"a":0,"k":{"c":false,"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[0,5.67],[5.67,0]]}}},{"ty":"sh","bm":0,"hd":false,"nm":"","d":1,"ks":{"a":0,"k":{"c":false,"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[9.71,15.38],[15.38,9.71]]}}},{"ty":"st","bm":0,"hd":false,"nm":"","lc":2,"lj":2,"ml":4,"o":{"a":0,"k":100},"w":{"a":0,"k":3.238095283508301},"c":{"a":0,"k":[0,0.4,1]}}],"ind":2,"parent":1},{"ty":4,"nm":"Transfer Illustration Path 2","sr":1,"st":0,"op":121,"ip":0,"hd":false,"ln":"Transfer Illustration Path 2 0","ddd":0,"bm":0,"hasMask":false,"ao":0,"ks":{"a":{"a":0,"k":[9.54,14.3]},"s":{"a":0,"k":[100,100]},"sk":{"a":0,"k":0},"p":{"a":0,"k":[24.46,14.77]},"r":{"a":0,"k":0},"sa":{"a":0,"k":0},"o":{"a":0,"k":100}},"shapes":[{"ty":"sh","bm":0,"hd":false,"nm":"","d":1,"ks":{"a":0,"k":{"c":true,"i":[[0,0],[0,-0.43],[0.04,-0.15],[0,0],[1.43,0],[0.6,0.88],[0,0],[0.14,0.14],[0,0]],"o":[[0.3,0.3],[0,0.16],[0,0],[-0.41,1.38],[-1.07,0],[0,0],[-0.11,-0.17],[0,0],[0,0]],"v":[[18.6,0],[19.07,1.14],[19,1.6],[11.71,26.27],[8.6,28.59],[5.93,27.18],[0.38,19.06],[0,18.6],[18.6,0]]}}},{"ty":"fl","bm":0,"hd":false,"nm":"","c":{"a":0,"k":[0,0.4,1]},"r":1,"o":{"a":0,"k":100}}],"ind":3,"parent":1},{"ty":4,"nm":"Transfer Illustration Path 1","sr":1,"st":0,"op":121,"ip":0,"hd":false,"ln":"Transfer Illustration Path 1 0","ddd":0,"bm":0,"hasMask":false,"ao":0,"ks":{"a":{"a":0,"k":[14.53,14.53]},"s":{"a":0,"k":[100,100]},"sk":{"a":0,"k":0},"p":{"a":0,"k":[19.47,14.53]},"r":{"a":0,"k":0},"sa":{"a":0,"k":0},"o":{"a":0,"k":100}},"shapes":[{"ty":"sh","bm":0,"hd":false,"nm":"","d":1,"ks":{"a":0,"k":{"c":true,"i":[[0,0],[0,0],[1.43,0],[0.6,0.88],[0,0],[0.33,0.23],[0,0],[0,1.07],[-1.38,0.41],[0,0],[-0.16,0],[0,-0.89],[0.04,-0.15]],"o":[[0,0],[-0.41,1.38],[-1.07,0],[0,0],[-0.23,-0.33],[0,0],[-0.88,-0.6],[0,-1.43],[0,0],[0.15,-0.04],[0.89,0],[0,0.16],[0,0]],"v":[[29,2.08],[21.7,26.75],[18.6,29.07],[15.92,27.65],[10.38,19.54],[9.53,18.69],[1.41,13.14],[0,10.47],[2.32,7.36],[26.99,0.07],[27.45,0],[29.07,1.62],[29,2.08]]}}},{"ty":"fl","bm":0,"hd":false,"nm":"","c":{"a":0,"k":[0.6196,0.7725,1]},"r":1,"o":{"a":0,"k":100}}],"ind":4,"parent":1}],"v":"5.7.0","fr":60,"op":120,"ip":0,"assets":[]} \ No newline at end of file diff --git a/src/assets/lottie/fee/won.json b/src/assets/lottie/fee/won.json new file mode 100644 index 0000000..a4b4afe --- /dev/null +++ b/src/assets/lottie/fee/won.json @@ -0,0 +1 @@ +{"nm":"Won Icon","ddd":0,"h":48,"w":48,"meta":{"g":"LottieFiles Figma (Figma Motion)"},"layers":[{"ty":4,"nm":"Won Icon","sr":1,"st":0,"op":121,"ip":0,"hd":false,"ln":"Won Icon 0","ddd":0,"bm":0,"hasMask":false,"ao":0,"ks":{"a":{"a":0,"k":[20,20]},"s":{"a":0,"k":[100,100]},"sk":{"a":0,"k":0},"p":{"a":0,"k":[24,24]},"r":{"a":0,"k":0},"sa":{"a":0,"k":0},"o":{"a":0,"k":100}},"shapes":[],"ind":1},{"ty":4,"nm":"Won Symbol","sr":1,"st":0,"op":121,"ip":0,"hd":false,"ln":"Won Symbol 0","ddd":0,"bm":0,"hasMask":false,"ao":0,"ks":{"a":{"a":0,"k":[9.62,7.13]},"s":{"a":0,"k":[100,100]},"sk":{"a":0,"k":0},"p":{"a":0,"k":[19.62,21.15]},"r":{"a":0,"k":0},"sa":{"a":0,"k":0},"o":{"a":0,"k":100}},"shapes":[],"ind":2,"parent":1},{"ty":4,"nm":"Won Symbol Stroke 2","sr":1,"st":0,"op":121,"ip":0,"hd":false,"ln":"Won Symbol Stroke 2 1","ddd":0,"bm":0,"hasMask":false,"ao":0,"ks":{"a":{"a":0,"k":[9.62,0]},"s":{"a":0,"k":[100,100]},"sk":{"a":0,"k":0},"p":{"a":0,"k":[9.62,4.96]},"r":{"a":0,"k":0},"sa":{"a":0,"k":0},"o":{"a":0,"k":100}},"shapes":[{"ty":"sh","bm":0,"hd":false,"nm":"","d":1,"ks":{"a":0,"k":{"c":false,"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[0,0],[19.23,0]]}}},{"ty":"tm","bm":0,"hd":false,"nm":"","e":{"a":1,"k":[{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[0],"t":0},{"o":{"x":0.25,"y":0.65},"i":{"x":0.35,"y":1},"s":[0],"t":25},{"s":[100],"t":66}]},"o":{"a":0,"k":0},"s":{"a":0,"k":0},"m":1},{"ty":"st","bm":0,"hd":false,"nm":"","lc":2,"lj":2,"ml":4,"o":{"a":0,"k":100},"w":{"a":0,"k":2.7295289039611816},"c":{"a":0,"k":[1,1,1]}}],"ind":3,"parent":2},{"ty":4,"nm":"Won Symbol Stroke 1","sr":1,"st":0,"op":121,"ip":0,"hd":false,"ln":"Won Symbol Stroke 1 1","ddd":0,"bm":0,"hasMask":false,"ao":0,"ks":{"a":{"a":0,"k":[7.83,7.13]},"s":{"a":0,"k":[100,100]},"sk":{"a":0,"k":0},"p":{"a":0,"k":[9.69,7.13]},"r":{"a":0,"k":0},"sa":{"a":0,"k":0},"o":{"a":0,"k":100}},"shapes":[{"ty":"sh","bm":0,"hd":false,"nm":"","d":1,"ks":{"a":0,"k":{"c":false,"i":[[0,0],[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0],[0,0]],"v":[[0,0],[3.72,14.27],[8.06,0],[11.88,14.27],[15.65,0]]}}},{"ty":"tm","bm":0,"hd":false,"nm":"","e":{"a":1,"k":[{"o":{"x":0.25,"y":0.65},"i":{"x":0.35,"y":1},"s":[0],"t":0},{"s":[100],"t":66}]},"o":{"a":0,"k":0},"s":{"a":0,"k":0},"m":1},{"ty":"st","bm":0,"hd":false,"nm":"","lc":2,"lj":2,"ml":4,"o":{"a":0,"k":100},"w":{"a":0,"k":2.7295289039611816},"c":{"a":0,"k":[1,1,1]}}],"ind":4,"parent":2},{"ty":4,"nm":"Won Icon Background 1","sr":1,"st":0,"op":121,"ip":0,"hd":false,"ln":"Won Icon Background 1 0","ddd":0,"bm":0,"hasMask":false,"ao":0,"ks":{"a":{"a":0,"k":[20,20]},"s":{"a":0,"k":[100,100]},"sk":{"a":0,"k":0},"p":{"a":0,"k":[20,20]},"r":{"a":0,"k":0},"sa":{"a":0,"k":0},"o":{"a":0,"k":100}},"shapes":[{"ty":"sh","bm":0,"hd":false,"nm":"","d":1,"ks":{"a":0,"k":{"c":true,"i":[[0,0],[11.05,0],[0,11.05],[-11.05,0],[0,-11.05]],"o":[[0,11.05],[-11.05,0],[0,-11.05],[11.05,0],[0,0]],"v":[[40,20],[20,40],[0,20],[20,0],[40,20]]}}},{"ty":"fl","bm":0,"hd":false,"nm":"","c":{"a":0,"k":[0.9961,0.8118,0.3608]},"r":1,"o":{"a":0,"k":100}}],"ind":5,"parent":1}],"v":"5.7.0","fr":60,"op":120,"ip":0,"assets":[]} \ No newline at end of file diff --git a/src/components/ui/ConfirmModal.tsx b/src/components/ui/ConfirmModal.tsx index 2ecc29a..91852f6 100644 --- a/src/components/ui/ConfirmModal.tsx +++ b/src/components/ui/ConfirmModal.tsx @@ -166,8 +166,11 @@ function ConfirmModal({ {title} {description && ( + // Figma 모달 설명은 줄바꿈 위치까지 디자인에 포함돼 있어서(예: K스마트멘토 이동 + // 확인 3562:162972) `\n`을 그대로 살린다. 기존 사용처는 모두 한 줄 문구라 영향이 없다. {title} + {/* Figma 설명 문구는 줄바꿈 위치까지 디자인에 포함돼 있어서(예: 송금 확인요청 완료 + 3562:163013) `\n`을 그대로 살린다. 기존 사용처는 모두 한 줄 문구라 영향이 없다. */} window.open(STUDENT_COUNCIL_KAKAO_CHANNEL_URL, "_blank", "noopener") + const openKakaoChannel = FEE_EXTERNAL_LINKS.studentCouncilKakaoChannel + ? () => openExternalLink(FEE_EXTERNAL_LINKS.studentCouncilKakaoChannel) : undefined; return ( diff --git a/src/features/fee/FeeTransferScreen.tsx b/src/features/fee/FeeTransferScreen.tsx new file mode 100644 index 0000000..29e5cd2 --- /dev/null +++ b/src/features/fee/FeeTransferScreen.tsx @@ -0,0 +1,102 @@ +import { FEE_EXTERNAL_LINKS } from "@/features/fee/constants/fee"; +import { + FEE_TRANSFER_STEP_OPTIONS, + type FeeTransferSteps, +} from "@/features/fee/constants/feeTransferFunnel"; +import FeeTransferCompleteStep from "@/features/fee/steps/FeeTransferCompleteStep"; +import FeeTransferConfirmStep from "@/features/fee/steps/FeeTransferConfirmStep"; +import FeeTransferSemesterStep from "@/features/fee/steps/FeeTransferSemesterStep"; +import FeeTransferTossStep from "@/features/fee/steps/FeeTransferTossStep"; +import FeeTransferTranscriptStep from "@/features/fee/steps/FeeTransferTranscriptStep"; +import { useBackNavigation } from "@/hooks/useBackNavigation"; +import { openExternalLink } from "@/lib/openExternalLink"; +import { useFunnel, useFunnelExit } from "@/lib/useFunnel"; + +// 학생회비 계좌 송금(남은 학기 수 → 학점이수현황 캡처 → 토스 이동 → 송금 확인 → 확인요청 완료)을 +// useFunnel 하나로 묶은 화면이다. 단계 화면은 `steps/`에 하나씩 나눠 두고, 이 파일은 단계 전환 +// (어느 단계에서 어디로, 무슨 값을 넘기는지)만 맡는다 — 로그인·온보딩 퍼널(AuthSignupScreen)과 같은 구성이다. +// 단계는 `/fee/transfer?fee.step=...`으로 나뉘고 단계마다 브라우저 뒤로가기가 한 단계씩 돌아간다. +// 단계 사이에 넘기는 값(남은 학기 수·캡처 이미지)은 퍼널 context가 들고 있어서 뒤로 돌아와도 유지된다. + +// 단계가 바뀌는 이동에만 슬라이드를 켠다 — 학기 수 입력·파일 선택처럼 같은 단계의 context만 +// 갈아 끼우는 replace에는 켜지 않는다. 뒤로가기는 React Router가 앞선 이동을 기억해 반대 방향으로 +// 자동 적용한다(coding-style.md 라우팅). +const SLIDE = { viewTransition: true }; + +// 주소에 단계가 없을 때(플로우 진입) 시작 단계. 모듈 스코프에 둬서 useFunnel이 매 렌더마다 +// 값을 다시 계산하지 않게 한다. +const INITIAL_STATE = { context: {}, step: "semester" } as const; + +function FeeTransferScreen() { + // 첫 단계의 뒤로가기 — 타고 들어왔으면 한 단계 뒤로, 플로우로 바로 들어왔으면(딥링크·앱 + // WebView 진입·주소 직접 입력) 홈으로 보낸다. `navigate(-1)`만 쓰면 먹통이 된다(#107 리뷰). + const goBackFromFlow = useBackNavigation("/"); + // 플로우를 끝내거나 미루고 나갈 때 — 쌓인 단계만큼 되돌아가서, 나간 뒤 뒤로가기로 단계가 + // 되살아나지 않게 한다(#107 리뷰). 퍼널로 바로 들어왔으면 홈으로 치환한다. + const exitFunnel = useFunnelExit("/"); + const funnel = useFunnel({ + id: "fee", + initial: INITIAL_STATE, + steps: FEE_TRANSFER_STEP_OPTIONS, + }); + + return ( + ( + exitFunnel(index)} /> + )} + confirm={({ history, index }) => ( + history.back()} + // 확인 요청을 보내면 송금 확인으로 돌아올 일이 없어서 히스토리를 대체한다 + onConfirm={() => history.replace("complete", {}, SLIDE)} + // 송금을 미루고 나가는 길 — 다음에 들어오면 플로우를 처음부터 다시 탄다 + onPostpone={() => exitFunnel(index)} + /> + )} + semester={({ context, history }) => ( + + history.replace("semester", { semester }) + } + onNext={(semester) => history.push("transcript", { semester }, SLIDE)} + /> + )} + toss={({ history }) => ( + history.back()} + onOpenToss={() => { + openExternalLink(FEE_EXTERNAL_LINKS.tossTransfer); + // 토스에 다녀오면 돌아올 화면이 송금 확인 단계다. 딥링크가 실제로 열렸는지는 + // 알 수 없어서 열기만 하고 넘긴다 — 송금하지 못했으면 그 단계에서 빠져나간다. + history.push("confirm", {}, SLIDE); + }} + /> + )} + transcript={({ context, history }) => ( + history.back()} + // 단계를 바꾸지 않고 context만 갈아 끼운다(replace) — 토스를 다녀와도 고른 파일이 남는다 + onChangeTranscript={(transcript) => + history.replace("transcript", (prev) => ({ ...prev, transcript })) + } + onNext={(transcript) => history.push("toss", { transcript }, SLIDE)} + // 주소를 아직 못 받아서 비어 있을 수 있다 — 빈 주소로 열면 빈 탭이 뜬다 + onOpenGuide={() => { + if (FEE_EXTERNAL_LINKS.kSmartMentor) { + openExternalLink(FEE_EXTERNAL_LINKS.kSmartMentor); + } + }} + /> + )} + /> + ); +} + +export default FeeTransferScreen; diff --git a/src/features/fee/components/FeeImagePreviewOverlay.tsx b/src/features/fee/components/FeeImagePreviewOverlay.tsx new file mode 100644 index 0000000..ee9251f --- /dev/null +++ b/src/features/fee/components/FeeImagePreviewOverlay.tsx @@ -0,0 +1,88 @@ +import { TopNavigationButton } from "@wanteddev/wds"; +import { IconClose } from "@wanteddev/wds-icon"; +import { useEffect, useRef } from "react"; +import { createPortal } from "react-dom"; + +import { useScreenSheetPortal } from "@/components/ui/useScreenSheetPortal"; + +interface FeeImagePreviewOverlayProps { + open: boolean; + src: string; + alt: string; + onClose: () => void; +} + +// Figma: 업로드한 이미지 미리보기 (nodeId 3595:70574) +// 업로드한 캡처를 전체 화면으로 키워 보는 오버레이. 헤더가 X 하나뿐이라 화면 라우트로 두지 않고 +// 업로드 화면 위에 포털로 띄운다 — 뒤로가기로 업로드 단계가 사라지면 안 되기 때문이다. +function FeeImagePreviewOverlay({ + open, + src, + alt, + onClose, +}: FeeImagePreviewOverlayProps) { + const portalEl = useScreenSheetPortal(); + const dialogRef = useRef(null); + // 미리보기를 연 버튼(파일 행) — 닫을 때 초점을 여기로 돌려준다 + const openerRef = useRef(null); + + // 열릴 때 초점을 오버레이 안으로 옮기고, 닫힐 때 열었던 버튼으로 되돌린다. 안 하면 오버레이가 + // 떠 있는 동안 초점이 뒤 화면에 남아서, 키보드·스크린리더로는 가려진 화면을 계속 조작하게 된다. + // 초점을 받을 요소는 닫기 버튼 하나뿐이라 컨테이너에서 첫 버튼을 찾는다(WDS 컴포넌트에 + // ref를 넘길 수 있는지에 기대지 않는다). + useEffect(() => { + // portalEl은 마운트 첫 렌더에 null일 수 있다 — 그때는 오버레이가 아직 안 그려져서 + // 초점을 옮길 버튼도 없다. 포털이 생긴 렌더에서 다시 돌게 의존성에 둔다. + if (!open || !portalEl) { + return; + } + openerRef.current = + document.activeElement instanceof HTMLElement + ? document.activeElement + : null; + dialogRef.current?.querySelector("button")?.focus(); + + return () => openerRef.current?.focus(); + }, [open, portalEl]); + + // 이미지 뷰어는 Esc로 닫는 게 기본 동작이라 모달과 같이 맞춘다 + useEffect(() => { + if (!open) { + return; + } + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") { + onClose(); + } + }; + document.addEventListener("keydown", handleKeyDown); + + return () => document.removeEventListener("keydown", handleKeyDown); + }, [open, onClose]); + + if (!portalEl || !open) { + return null; + } + + return createPortal( +
+
+ + + +
+
+ {alt} +
+
, + portalEl, + ); +} + +export default FeeImagePreviewOverlay; diff --git a/src/features/fee/components/FeeImageUploadButton.tsx b/src/features/fee/components/FeeImageUploadButton.tsx new file mode 100644 index 0000000..9fb733e --- /dev/null +++ b/src/features/fee/components/FeeImageUploadButton.tsx @@ -0,0 +1,55 @@ +import { Typography } from "@wanteddev/wds"; +import { IconUpload } from "@wanteddev/wds-icon"; +import { useId } from "react"; + +import { FEE_TRANSCRIPT_ACCEPT } from "@/features/fee/constants/fee"; + +interface FeeImageUploadButtonProps { + onSelect: (file: File) => void; +} + +// Figma: Image Upload Button (nodeId 3716:23609) — WDS가 아니라 Stream 로컬 컴포넌트다. +// `search_design_system`에 같은 모양의 아웃라인 버튼이 없고, 안쪽 아이콘만 WDS +// `Icon/Normal/Upload`(3716:22984)다. WDS Button의 outlined는 radius·높이가 달라서 쓰지 않는다. +// +// 파일 선택은 보이지 않는 에 위임한다 — label로 감싸면 버튼 모양을 그대로 +// 두면서도 키보드·스크린리더에서 하나의 컨트롤로 읽힌다. +// +// radius만 `rounded-[10px]`로 남긴다 — Figma가 10px인데 Tailwind 단계는 8px(lg)·12px(xl)뿐이고 +// 이 버튼 하나만 쓰는 값이라 토큰까지 만들지 않았다(component-convention.md "4. 예외"). +function FeeImageUploadButton({ onSelect }: FeeImageUploadButtonProps) { + const inputId = useId(); + + return ( + + ); +} + +export default FeeImageUploadButton; diff --git a/src/features/fee/components/FeeLottieIcon.tsx b/src/features/fee/components/FeeLottieIcon.tsx new file mode 100644 index 0000000..23c8538 --- /dev/null +++ b/src/features/fee/components/FeeLottieIcon.tsx @@ -0,0 +1,44 @@ +import { LottieLight } from "lottie-react"; + +import { usePrefersReducedMotion } from "@/hooks/usePrefersReducedMotion"; + +interface FeeLottieIconProps { + /** LottieFiles 플러그인으로 받은 Figma Motion json */ + animation: object; + /** + * 움직임이 끝나 값이 고정되는 프레임. 타임라인은 2초지만 실제 움직임은 앞부분에서 끝나고 + * 나머지는 멈춰 있는 구간이라, 모션 줄이기일 때 이 한 장만 세워 둔다. + */ + settledFrame: number; + /** 스크린리더에 읽힐 이름. 장식이면 생략한다 */ + label?: string; +} + +// 송금 플로우의 48px 일러스트 두 개(₩ 3562:163032, 종이비행기 3562:162980)가 공유하는 재생기. +// 둘 다 Figma Motion이 붙어 있어 키프레임을 손으로 옮기지 않고 Lottie로 받아 재생한다 +// (component-convention.md "모션 (Lottie)"). +// +// `LottieLight`를 쓰는 이유는 완료 체크(CompleteCheck)와 같다 — 풀 빌드는 canvas·HTML 렌더러와 +// `eval` 기반 표현식 엔진까지 끌고 와서 번들이 커지고 WebView CSP에서 깨질 수 있다. +function FeeLottieIcon({ animation, settledFrame, label }: FeeLottieIconProps) { + const shouldReduceMotion = usePrefersReducedMotion(); + + return ( + + ); +} + +export default FeeLottieIcon; diff --git a/src/features/fee/components/FeeTranscriptGuide.tsx b/src/features/fee/components/FeeTranscriptGuide.tsx new file mode 100644 index 0000000..75ef873 --- /dev/null +++ b/src/features/fee/components/FeeTranscriptGuide.tsx @@ -0,0 +1,44 @@ +import { Typography } from "@wanteddev/wds"; + +import transcriptExample from "@/assets/images/fee/transcript-example.png"; + +// Figma: Transcript Preview Section (nodeId 3562:162857) +// 어떤 화면을 캡처해야 하는지 보여주는 예시 카드. +// +// 안쪽 그림(3562:162858)은 Figma에서 실제 K-스마트멘토 스크린샷 위에 가림 박스 9개와 가짜 값 +// (소프트웨어전공 / 20260000 / 홍길동 등)을 얹어 만든 것이다. 그래서 레이어별로 쪼개진 +// `svgAssets`나 원본 `rawImages`를 쓰면 안 되고, 가림까지 합쳐진 `export` PNG만 커밋한다 — +// 커밋 전에 가림 박스가 전부 불투명한지(밑글씨가 비치지 않는지) 픽셀로 확인했다. +// 3배 크기(840x615)로 받아서 고해상도 화면에서도 또렷하게 보이게 한다. +function FeeTranscriptGuide() { + return ( + // Figma 카드는 248px에서 잘린다 — 예시 이미지 아래쪽(이수학기 행)이 일부러 잘려 보인다 + // 카드 높이를 고정하지 않는다 — 화면 프레임이 w-full / sm:w-[480px]라 카드 폭이 변하는데, + // 248px(375 기준)로 박으면 넓은 화면에서 그림 아래가 잘린다. 아래 여백 없이 그림이 카드 + // 바닥에 붙는 Figma 구조(3562:162857: 위 16px, 내용 231.76px, 합 248px) 그대로 내용이 + // 높이를 정하게 두면 설계 폭에서 자동으로 248px이 된다. 모서리 때문에 overflow만 숨긴다. + // 간격 13px(gap-3.25)도 그 합(위 16 + 글자 13.76 + 13 + 그림 205 = 248)에서 나온 값이다. +
+ + 캡처 예시 + + {/* Figma 그림 크기는 280px 고정이다. 카드가 그보다 좁아지면 Tailwind preflight의 + max-width:100%가 알아서 줄여준다 — 넓어져도 커지지는 않게 w-full을 쓰지 않는다. */} + 학점이수현황 캡처 예시 — 학생정보와 이수학기가 보이는 화면 +
+ ); +} + +export default FeeTranscriptGuide; diff --git a/src/features/fee/components/FeeTransferStepLayout.tsx b/src/features/fee/components/FeeTransferStepLayout.tsx new file mode 100644 index 0000000..dc6f9bc --- /dev/null +++ b/src/features/fee/components/FeeTransferStepLayout.tsx @@ -0,0 +1,114 @@ +import { ActionArea, TopNavigationButton, Typography } from "@wanteddev/wds"; +import { IconChevronLeft } from "@wanteddev/wds-icon"; +import type { ReactNode } from "react"; + +import ScreenHeader from "@/components/ui/ScreenHeader"; +import { useScreenHeader } from "@/components/ui/useScreenHeader"; + +// 본문 스택 간격. Figma가 화면마다 다르게 잡아뒀다 — +// 토스 안내·실패는 8px(3562:163031), 송금 확인은 12px(3562:162979), +// 입력이 있는 단계는 16px(3562:162839, 3562:162853). +type FeeTransferContentGap = 8 | 12 | 16; + +const CONTENT_GAP_CLASS_NAMES: Record = { + 8: "gap-2", + 12: "gap-3", + 16: "gap-4", +}; + +interface FeeTransferStepLayoutProps { + /** 제목 위에 놓이는 48px 일러스트 */ + illustration?: ReactNode; + title: ReactNode; + description?: ReactNode; + contentGap?: FeeTransferContentGap; + /** 제목/설명 아래에 붙는 입력·카드 등 */ + children?: ReactNode; + /** ActionArea 안에 들어갈 버튼들 */ + actions: ReactNode; + /** 헤더 뒤로가기 — 퍼널이 단계에 맞는 동작(이전 단계로, 또는 플로우 밖으로)을 넘긴다 */ + onBack: () => void; + /** + * 주 버튼 아래에 텍스트 버튼을 세로로 쌓을 때(송금 확인 화면) `"strong"`을 준다 — + * WDS가 그때만 세로 배치 + 8px 간격이 되어 Figma Contents(3562:162974)와 맞는다. + */ + actionsVariant?: "strong"; +} + +// Figma: 계좌 송금 퍼널의 단계 화면들이 공유하는 뼈대 — +// 남은 학기 수(3562:162833) / 학점이수현황 업로드(3562:162845) / +// 토스 이동 안내(3562:163025) / 토스 이동 실패(3562:163046) / 송금 확인(3562:162973). +// +// 네 단계 모두 `뒤로가기만 있는 헤더 → 12px → (일러스트) 제목·설명 → 본문 → 하단 고정 버튼` +// 구조가 같고 간격과 문구만 다르다. 제목은 Label/Strong(#000000)으로, 설명은 +// Label/Alternative로 고정이라 prop으로 빼지 않았다. +// +// 완료 단계(3562:163013)만 이 뼈대를 쓰지 않는다 — 뒤로가기가 아니라 X로 닫고 중앙 정렬이라 +// 신청 결과 화면 공용 컴포넌트(ResultScreen)와 같은 모양이다. +function FeeTransferStepLayout({ + illustration, + title, + description, + contentGap = 16, + children, + actions, + actionsVariant, + onBack, +}: FeeTransferStepLayoutProps) { + useScreenHeader( + + + + } + variant="normal" + />, + ); + + return ( +
+ {/* Figma Student Fee Transfer Content: 헤더 아래 12px */} +
+ {illustration} +
+ + {title} + + {description && ( + // Figma 설명 문구는 줄바꿈 위치까지 디자인에 포함돼 있다 + + {description} + + )} +
+ {children} +
+ +
+ {actions} + {/* 다른 하단 고정 버튼 화면과 같은 14px — WDS ActionArea 아래 padding 20px에 Home Bar 여백을 더해 34px */} +
+
+
+ ); +} + +export default FeeTransferStepLayout; diff --git a/src/features/fee/components/FeeUploadedFileCard.tsx b/src/features/fee/components/FeeUploadedFileCard.tsx new file mode 100644 index 0000000..afa0873 --- /dev/null +++ b/src/features/fee/components/FeeUploadedFileCard.tsx @@ -0,0 +1,67 @@ +import { Typography } from "@wanteddev/wds"; +import { IconTrash } from "@wanteddev/wds-icon"; + +import imageFileIcon from "@/assets/icons/fee/image-file.svg"; + +interface FeeUploadedFileCardProps { + fileName: string; + /** 파일 행을 누르면 원본을 크게 본다(업로드한 이미지 미리보기 3595:70574) */ + onPreview: () => void; + onRemove: () => void; +} + +// Figma: Uploaded Image (nodeId 3562:162918) +// 흰 카드 + 1px 테두리 안에 파일 행 하나가 들어간다. 행 배경 Atomic/Blue/99(#F7FBFF)는 +// primary-subtle(Blue/95)보다 훨씬 옅은 별개 값이라 index.css에 토큰을 따로 추가했다. +// +// 파일 아이콘(28px)은 WDS가 아니라 Figma가 쓴 `fa7-solid:image` 글리프다 — WDS `IconImage`는 +// 외곽선 스타일이라 모양이 달라서 SVG를 받아 커밋했다. +function FeeUploadedFileCard({ + fileName, + onPreview, + onRemove, +}: FeeUploadedFileCardProps) { + return ( + // 테두리는 border가 아니라 outline으로 그린다 — Figma는 335×104 안쪽에 303px 행을 두는데, + // CSS border는 box-sizing 때문에 가로·세로를 각각 2px씩 깎아먹는다 +
+ + 업로드한 이미지 + +
+ + +
+
+ ); +} + +export default FeeUploadedFileCard; diff --git a/src/features/fee/constants/fee.ts b/src/features/fee/constants/fee.ts index 0e4b109..afd3d53 100644 --- a/src/features/fee/constants/fee.ts +++ b/src/features/fee/constants/fee.ts @@ -1,7 +1,29 @@ import type { FeePaymentStatus } from "@/entities/fee/types"; -// 학생회 카카오톡 채널 — 주소를 받으면 채운다. 비어 있으면 확인필요 카드에 문의하기 버튼을 그리지 않는다. -export const STUDENT_COUNCIL_KAKAO_CHANNEL_URL = ""; +// 학생회비 계좌 송금 플로우 경로. 화면설계서(687:20584)에는 경로 표기 컬럼이 아예 없어서 +// 용어 사전(terminology.md)의 `회비 납부 → fee`와 "프론트 라우트 경로: /{코드 용어}" 규칙으로 정했다. +// 단계(남은 학기 수·캡처 업로드·토스 이동·송금 확인·완료)는 이 경로 하나 안에서 useFunnel이 +// `?fee.step=`으로 나눈다 — 로그인·온보딩 퍼널(`/auth`)과 같은 방식이다. +export const FEE_TRANSFER_PATH = "/fee/transfer"; + +// 학생회비 납부내역 화면 경로 +export const FEE_PAYMENTS_PATH = "/my/fee"; + +// 아직 받지 못한 외부 연동 주소들. 값이 있을 때만 열도록 해서 빈 창이 뜨는 걸 막는다. +// +// - kSmartMentor: 학점이수현황을 확인하는 국민대 K-스마트멘토 주소 +// - studentCouncilKakaoChannel: 학생회 카카오톡 채널. 비어 있으면 확인필요 카드에 +// 문의하기 버튼을 그리지 않는다. +// - tossTransfer: 계좌·금액이 채워진 토스 송금 딥링크. 학생회 계좌와 납부 금액 규칙이 정해져야 +// 만들 수 있다(남은 학기 수에 따라 금액이 달라진다). +export const FEE_EXTERNAL_LINKS = { + kSmartMentor: "", + studentCouncilKakaoChannel: "", + tossTransfer: "", +} as const; + +// 업로드 받는 이미지 형식 — 학점이수현황 "캡처"라 스크린샷 확장자만 받는다 +export const FEE_TRANSCRIPT_ACCEPT = "image/png,image/jpeg,image/webp"; // 학생회비 납부 상태 배지 — 납부내역 카드와 홈 내 정보 목록(HomeInfoList)이 같이 쓴다 // Figma: 학생회비 납부 여부 뱃지 (nodeId 3147:146475) @@ -13,6 +35,3 @@ export const FEE_STATUS_BADGES: Record< "needs-check": { color: "red", label: "확인필요" }, paid: { color: "green", label: "납부완료" }, }; - -// 학생회비 납부내역 화면 경로 -export const FEE_PAYMENTS_PATH = "/my/fee"; diff --git a/src/features/fee/constants/feeTransferFunnel.ts b/src/features/fee/constants/feeTransferFunnel.ts new file mode 100644 index 0000000..c06fb62 --- /dev/null +++ b/src/features/fee/constants/feeTransferFunnel.ts @@ -0,0 +1,61 @@ +// 학생회비 계좌 송금(남은 학기 수 → 학점이수현황 캡처 → 토스 이동 → 송금 확인 → 확인요청 완료) +// 퍼널의 단계와 단계별 context 타입이다. +// Figma: 남은 학기 수(nodeId 3562:162833) / 학점이수현황 업로드(3562:162845) / +// 토스 이동 안내(3562:163025) / 송금 확인(3562:162973) / 확인요청 완료(3562:163013). +// +// 캡처 이미지는 `File`을 context에 그대로 담는다 — `File`은 구조화 복제가 되는 값이라 +// history.state에 실려서, 토스 단계에서 뒤로 돌아와도 고른 파일이 남는다(별도 저장소 불필요). +// 다만 history.state는 브라우저마다 크기 상한이 있어서(Firefox 16MiB) 업로드 API가 붙으면 +// 파일 대신 서버가 준 식별자를 담는 쪽으로 바꾸는 게 안전하다. +export type FeeTransferSteps = { + semester: { semester?: number }; + transcript: { semester: number; transcript?: File }; + toss: { semester: number; transcript: File }; + confirm: { semester: number; transcript: File }; + complete: Record; +}; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +// 남은 학기 수로 받을 수 있는 값. 0학기는 납부할 이유가 없고, 상한은 실제 학적 기준이 없어 +// 두 자리까지만 받는다 — 서버 검증 규칙이 정해지면 맞춘다. +export const SEMESTER_PATTERN = /^[1-9][0-9]?$/; + +function isSemester(value: unknown): value is number { + return typeof value === "number" && SEMESTER_PATTERN.test(String(value)); +} + +// 새로고침이나 주소 직접 입력처럼 앞 단계의 값이 없는 채로 중간 단계에 들어오면 guard가 걸러서 +// 첫 단계(남은 학기 수)로 돌려보낸다 — 학기 수나 캡처 없이 토스 이동 화면이 열리는 일이 없다. +// `File`은 history.state에서 되살아나지 못하는 경우가 있어 `instanceof`로 확인한다. +export const FEE_TRANSFER_STEP_OPTIONS = { + complete: { + guard: (data: unknown): data is FeeTransferSteps["complete"] => + isRecord(data), + }, + confirm: { + guard: (data: unknown): data is FeeTransferSteps["confirm"] => + isRecord(data) && + isSemester(data.semester) && + data.transcript instanceof File, + }, + semester: { + guard: (data: unknown): data is FeeTransferSteps["semester"] => + isRecord(data) && + (data.semester === undefined || isSemester(data.semester)), + }, + toss: { + guard: (data: unknown): data is FeeTransferSteps["toss"] => + isRecord(data) && + isSemester(data.semester) && + data.transcript instanceof File, + }, + transcript: { + guard: (data: unknown): data is FeeTransferSteps["transcript"] => + isRecord(data) && + isSemester(data.semester) && + (data.transcript === undefined || data.transcript instanceof File), + }, +}; diff --git a/src/features/fee/steps/FeeTransferCompleteStep.tsx b/src/features/fee/steps/FeeTransferCompleteStep.tsx new file mode 100644 index 0000000..2d9426c --- /dev/null +++ b/src/features/fee/steps/FeeTransferCompleteStep.tsx @@ -0,0 +1,23 @@ +import CompleteCheck from "@/components/ui/CompleteCheck"; +import ResultScreen from "@/components/ui/ResultScreen"; + +// Figma: 송금 확인요청 완료 페이지 (nodeId 3562:163013) +// 체크 모션이 신청 완료 화면들과 같은 메인 컴포넌트(Circle Check Motion 1712:192849)의 +// 인스턴스라 ResultScreen + CompleteCheck를 그대로 쓴다. 버튼이 하나뿐이라 secondaryAction은 없다. +// 뒤로가기(←)가 아니라 X로 닫는 화면이라 다른 단계와 달리 FeeTransferStepLayout을 쓰지 않는다. +function FeeTransferCompleteStep({ onGoHome }: { onGoHome: () => void }) { + return ( + } + illustrationGap={8} + onClose={onGoHome} + primaryAction={{ label: "홈으로 가기", onClick: onGoHome }} + title="송금 확인을 요청했어요" + /> + ); +} + +export default FeeTransferCompleteStep; diff --git a/src/features/fee/steps/FeeTransferConfirmStep.tsx b/src/features/fee/steps/FeeTransferConfirmStep.tsx new file mode 100644 index 0000000..581526c --- /dev/null +++ b/src/features/fee/steps/FeeTransferConfirmStep.tsx @@ -0,0 +1,54 @@ +import { ActionAreaButton } from "@wanteddev/wds"; + +import sendFastMotion from "@/assets/lottie/fee/send-fast.json"; +import FeeLottieIcon from "@/features/fee/components/FeeLottieIcon"; +import FeeTransferStepLayout from "@/features/fee/components/FeeTransferStepLayout"; + +// Figma: 계좌 송금 - 토스 다녀와서 확인 버튼 (nodeId 3562:162973) +// 토스에서 송금하고 돌아온 사용자에게 확인을 받는 단계. 실제 입금 확인은 학생회가 수동으로 +// 하기 때문에 여기서 "네"를 눌러야 확인 요청이 접수된다. +// +// 종이비행기(3562:162980)는 기울기·크기·위치가 함께 튀어 들어오는(overshoot) Figma Motion이라 +// Lottie로 받아 재생한다. 플러그인이 이징을 매 프레임으로 구워서 키프레임만 보면 끝을 알 수 +// 없는데, 실제 값은 49프레임(0.82초)부터 고정이다 — rotation 6° → -2.5° → 1° → 0°로 잦아든다. +function FeeTransferConfirmStep({ + onBack, + onConfirm, + onPostpone, +}: { + onBack: () => void; + onConfirm: () => void; + /** 송금을 미루고 플로우를 빠져나간다 */ + onPostpone: () => void; +}) { + return ( + + + 네, 송금했어요 + + {/* 송금을 미루고 빠져나가는 길 — 플로우를 처음부터 다시 타도록 홈으로 보낸다. + Figma Sub Action은 44px인데 WDS 텍스트 버튼은 28px이라 세로 padding을 보정하고, + WDS가 따로 주는 세로 margin 8px은 지운다(안 지우면 Figma의 8px 간격이 16px이 된다). */} + + 다음에 할게요 + + + } + actionsVariant="strong" + contentGap={12} + illustration={ + + } + onBack={onBack} + title="송금을 완료하셨나요?" + /> + ); +} + +export default FeeTransferConfirmStep; diff --git a/src/features/fee/steps/FeeTransferSemesterStep.tsx b/src/features/fee/steps/FeeTransferSemesterStep.tsx new file mode 100644 index 0000000..1b8940d --- /dev/null +++ b/src/features/fee/steps/FeeTransferSemesterStep.tsx @@ -0,0 +1,66 @@ +import { ActionAreaButton, TextField } from "@wanteddev/wds"; +import { useState } from "react"; + +import FeeTransferStepLayout from "@/features/fee/components/FeeTransferStepLayout"; +import { SEMESTER_PATTERN } from "@/features/fee/constants/feeTransferFunnel"; + +// Figma: 계좌 송금 - 남은 학기 수 (nodeId 3562:162833) +// 숫자만 받고, 유효한 값이 들어왔을 때만 "다음"이 활성화된다(Figma 기본 상태가 비활성). +function FeeTransferSemesterStep({ + initialSemester, + onBack, + onChangeSemester, + onNext, +}: { + initialSemester: number | undefined; + onBack: () => void; + /** 입력할 때마다 퍼널 context에 반영한다 — 캡처 단계에서 뒤로 돌아와도 학기 수가 남는다 */ + onChangeSemester: (semester: number | undefined) => void; + onNext: (semester: number) => void; +}) { + const [digits, setDigits] = useState(() => + initialSemester === undefined ? "" : String(initialSemester), + ); + const isValid = SEMESTER_PATTERN.test(digits); + + return ( + onNext(Number(digits))} + sx={{ paddingBlock: "16px" }} + > + 다음 + + } + description="남은 학기 수에 따라 납부 금액이 달라져요." + onBack={onBack} + title={ + <> + 현재 남은 학기 수를 +
+ 알려 주세요 + + } + > + { + const nextDigits = event.target.value + .replace(/[^0-9]/g, "") + .slice(0, 2); + setDigits(nextDigits); + onChangeSemester( + SEMESTER_PATTERN.test(nextDigits) ? Number(nextDigits) : undefined, + ); + }} + placeholder="ex) 3" + value={digits} + /> +
+ ); +} + +export default FeeTransferSemesterStep; diff --git a/src/features/fee/steps/FeeTransferTossStep.tsx b/src/features/fee/steps/FeeTransferTossStep.tsx new file mode 100644 index 0000000..6dcf3e2 --- /dev/null +++ b/src/features/fee/steps/FeeTransferTossStep.tsx @@ -0,0 +1,89 @@ +import { ActionAreaButton } from "@wanteddev/wds"; +import { useState } from "react"; + +import wonMotion from "@/assets/lottie/fee/won.json"; +import FeeLottieIcon from "@/features/fee/components/FeeLottieIcon"; +import FeeTransferStepLayout from "@/features/fee/components/FeeTransferStepLayout"; + +// Figma: 계좌 송금 - 토스로 이동 안내 (nodeId 3562:163025) / 토스 이동 실패 (3562:163046) +// +// 두 프레임은 같은 단계의 상태 차이다 — 실패하면 일러스트가 빠지고 문구와 버튼 라벨만 바뀐다. +// 그래서 퍼널 단계를 나누지 않고 실패 여부 state로 토글한다. +// +// 토스는 딥링크라 열렸는지 브라우저가 알려주지 않는다. 그래서 실패 화면은 "링크 주소가 아직 +// 없을 때"(`hasTossLink`)만 띄운다 — 주소가 채워지면 열고 바로 송금 확인 단계로 넘어간다. +// 실제로 여는 일은 퍼널(FeeTransferScreen)이 `lib/openExternalLink`로 맡는다. +// +// ₩ 아이콘(3562:163032)은 획이 그려지는 Figma Motion이라 Lottie로 받아 재생한다. 2초 +// 타임라인이지만 실제 움직임은 66프레임(1.1초)에 끝나고 나머지는 멈춰 있는 구간이다 — +// json의 키프레임이 실제로 66에서 끝나는 걸 확인했다. +function FeeTransferTossStep({ + hasTossLink, + onBack, + onOpenToss, +}: { + /** 토스 딥링크 주소가 준비됐는지 — 없으면 누를 때 이동 실패 화면으로 바꾼다 */ + hasTossLink: boolean; + onBack: () => void; + /** 토스를 열고 송금 확인 단계로 넘긴다 */ + onOpenToss: () => void; +}) { + const [hasFailed, setFailed] = useState(false); + + const handleOpenToss = () => { + if (!hasTossLink) { + setFailed(true); + return; + } + onOpenToss(); + }; + + if (hasFailed) { + return ( + + 토스로 다시 이동하기 + + } + contentGap={8} + description={ + "잠시 후 다시 시도해 주세요.\n계속 열리지 않으면 토스 앱을 확인해 주세요." + } + onBack={onBack} + title="토스를 열지 못했어요" + /> + ); + } + + return ( + + 토스로 이동하기 + + } + contentGap={8} + description={ + "송금 후 이 화면으로 돌아와\n송금 완료 여부를 꼭 확인해 주세요." + } + illustration={} + onBack={onBack} + title={ + <> + 이제 토스에서 학생회비를 +
+ 송금해 주세요 + + } + /> + ); +} + +export default FeeTransferTossStep; diff --git a/src/features/fee/steps/FeeTransferTranscriptStep.tsx b/src/features/fee/steps/FeeTransferTranscriptStep.tsx new file mode 100644 index 0000000..ba3835d --- /dev/null +++ b/src/features/fee/steps/FeeTransferTranscriptStep.tsx @@ -0,0 +1,129 @@ +import { ActionAreaButton, Typography } from "@wanteddev/wds"; +import { useEffect, useState } from "react"; + +import ConfirmModal from "@/components/ui/ConfirmModal"; +import FeeImagePreviewOverlay from "@/features/fee/components/FeeImagePreviewOverlay"; +import FeeImageUploadButton from "@/features/fee/components/FeeImageUploadButton"; +import FeeTranscriptGuide from "@/features/fee/components/FeeTranscriptGuide"; +import FeeTransferStepLayout from "@/features/fee/components/FeeTransferStepLayout"; +import FeeUploadedFileCard from "@/features/fee/components/FeeUploadedFileCard"; + +// Figma: 계좌 송금 - 이미지 업로드하기 전 (nodeId 3562:162845) / 업로드 후 (3562:162891) / +// K-스마트멘토 이동 확인 모달 (3562:162940) / 업로드한 이미지 미리보기 (3595:70574) +// +// 네 프레임이 한 단계의 상태 변화라 퍼널 단계를 나누지 않고 업로드 여부·모달 열림으로 토글한다. +// 업로드 API가 아직 없어서 고른 파일을 퍼널 context에 담아 두기만 한다 — 미리보기는 objectURL로 +// 띄우고, 연동할 때 이 자리에서 업로드 요청을 보내면 된다. +// K-스마트멘토를 여는 일은 퍼널(FeeTransferScreen)이 맡고 여기는 `onOpenGuide`만 부른다. +function FeeTransferTranscriptStep({ + transcript, + onBack, + onChangeTranscript, + onNext, + onOpenGuide, +}: { + /** 고른 캡처 — 퍼널 context가 들고 있는 값이 유일한 출처다 */ + transcript: File | undefined; + onBack: () => void; + /** + * 고르거나 지울 때마다 퍼널 context를 갈아 끼운다 — 로컬 state에 같은 값을 또 두지 않는다. + * `history.replace`가 새 context로 다시 그려 주므로 이 화면은 context만 읽으면 된다. + */ + onChangeTranscript: (transcript: File | undefined) => void; + onNext: (transcript: File) => void; + /** 이동 확인 모달에서 "이동하기"를 눌렀을 때 — K-스마트멘토를 연다 */ + onOpenGuide: () => void; +}) { + const [previewUrl, setPreviewUrl] = useState(""); + const [isPreviewOpen, setPreviewOpen] = useState(false); + const [isGuideModalOpen, setGuideModalOpen] = useState(false); + + // objectURL은 안 풀어주면 파일을 바꿔 고를 때마다 메모리에 쌓인다 + useEffect(() => { + if (!transcript) { + setPreviewUrl(""); + return; + } + const url = URL.createObjectURL(transcript); + setPreviewUrl(url); + + return () => URL.revokeObjectURL(url); + }, [transcript]); + + const handleGuideConfirm = () => { + setGuideModalOpen(false); + onOpenGuide(); + }; + + return ( + <> + transcript && onNext(transcript)} + sx={{ paddingBlock: "16px" }} + > + 다음 + + } + description="홈 > ‘학점이수현황 자세히 보기’에 있어요." + onBack={onBack} + title={ + <> + {/* Figma에서 "K스마트멘토↗"만 primary + 밑줄이고, 누르면 이동 확인 모달이 뜬다 */} + + 에서 +
+ 학점이수현황을 캡처해 주세요 + + } + > + + {transcript ? ( + setPreviewOpen(true)} + onRemove={() => onChangeTranscript(undefined)} + /> + ) : ( + + )} +
+ + setGuideModalOpen(false)} + onConfirm={handleGuideConfirm} + open={isGuideModalOpen} + title="K스마트멘토로 이동할까요?" + /> + + setPreviewOpen(false)} + open={isPreviewOpen && Boolean(previewUrl)} + src={previewUrl} + /> + + ); +} + +export default FeeTransferTranscriptStep; diff --git a/src/features/home/HomeScreen.tsx b/src/features/home/HomeScreen.tsx index 2040be7..e58d7cd 100644 --- a/src/features/home/HomeScreen.tsx +++ b/src/features/home/HomeScreen.tsx @@ -9,7 +9,10 @@ import { ARCHIVES_PATH } from "@/features/archives/constants/archives"; import { BILILGE_TAB_PATHS } from "@/features/bililge/constants/bililge"; import { useBililgeReturnRequest } from "@/features/bililge/hooks/useBililgeReturnRequest"; import { EVENTS_TAB_PATHS } from "@/features/events/constants/events"; -import { FEE_PAYMENTS_PATH } from "@/features/fee/constants/fee"; +import { + FEE_PAYMENTS_PATH, + FEE_TRANSFER_PATH, +} from "@/features/fee/constants/fee"; import { MY_FEEDBACKS_PATH } from "@/features/feedbacks/constants/feedbacks"; import HomeAppliedEventSection from "@/features/home/components/HomeAppliedEventSection"; import HomeApplyCard from "@/features/home/components/HomeApplyCard"; @@ -144,6 +147,7 @@ function HomeScreen() { push(FEE_PAYMENTS_PATH)} + onSelectFeeTransfer={() => push(FEE_TRANSFER_PATH)} onSelectFeedbacks={() => push(MY_FEEDBACKS_PATH)} onSelectLocker={() => push(LOCKER_ASSIGNMENT_PATH)} /> diff --git a/src/features/home/components/HomeInfoList.tsx b/src/features/home/components/HomeInfoList.tsx index 061b37b..7dfe4b9 100644 --- a/src/features/home/components/HomeInfoList.tsx +++ b/src/features/home/components/HomeInfoList.tsx @@ -41,7 +41,7 @@ interface InfoRowProps { badge?: InfoBadge; // 학생회비 납부하기는 앱 밖(송금)으로 나가서 ↗ 화살표다 isExternal?: boolean; - // 없으면 누를 수 없는 줄로 그린다(아직 화면이 없는 학생회비 납부하기) + // 없으면 누를 수 없는 줄로 그린다 onSelect?: () => void; } @@ -115,6 +115,8 @@ function InfoRow({ interface HomeInfoListProps { myInfo: HomeMyInfo; onSelectFee: () => void; + /** 미납 상태의 "학생회비 납부하기" — 계좌 송금 플로우로 보낸다 */ + onSelectFeeTransfer: () => void; onSelectFeedbacks: () => void; onSelectLocker: () => void; } @@ -122,6 +124,7 @@ interface HomeInfoListProps { function HomeInfoList({ myInfo, onSelectFee, + onSelectFeeTransfer, onSelectFeedbacks, onSelectLocker, }: HomeInfoListProps) { @@ -130,10 +133,11 @@ function HomeInfoList({ return (
{feeStatus === "unpaid" ? ( - // TODO: 학생회비 계좌 송금 화면(#105)이 머지되면 onSelect로 연결한다 diff --git a/src/index.css b/src/index.css index 5fae147..ee1b4ee 100644 --- a/src/index.css +++ b/src/index.css @@ -56,6 +56,10 @@ /* 사물함 칸 선택 — 선택 가능한 칸 배경(Figma "Orange/95")과 칸 묶음 테두리(Figma "Line/Solid/Alternative") */ --color-orange-95: var(--atomic-orange-95); + + /* 학생회비 송금 — 업로드한 파일 행 배경(Figma "Atomic/Blue/99", 3562:162922). + primary-subtle(Blue/95)보다 훨씬 옅어서 흰 카드 위에서 행만 살짝 떠 보인다 */ + --color-blue-99: var(--atomic-blue-99); --color-line-solid-alternative: var(--semantic-line-solid-alternative); --color-status-positive: var(--semantic-status-positive); diff --git a/src/lib/historyIndex.ts b/src/lib/historyIndex.ts index bf2d505..c540974 100644 --- a/src/lib/historyIndex.ts +++ b/src/lib/historyIndex.ts @@ -2,8 +2,8 @@ // 화면으로 바로 들어오면(딥링크·앱 WebView 진입·새로고침 후 첫 화면) 0이다. // // react-router 내부가 정한 형태라 라이브러리를 올릴 때 깨질 수 있는 자리다. 여러 곳에서 각자 -// 캐스팅하지 않고 여기서만 읽는다 — 읽는 쪽은 `ScreenLayoutRoute`(전환 방향 판정)와 -// `useBackNavigation`(뒤로 갈 곳이 있는지)뿐이다. +// 캐스팅하지 않고 여기서만 읽는다 — 읽는 쪽은 `ScreenLayoutRoute`(전환 방향 판정), +// `useBackNavigation`(뒤로 갈 곳이 있는지), `useFunnelExit`(퍼널 단계 수만큼 되돌아갈 수 있는지)뿐이다. export function getHistoryIndex(): number | null { const state: unknown = window.history.state; if ( diff --git a/src/lib/openExternalLink.ts b/src/lib/openExternalLink.ts new file mode 100644 index 0000000..a102e20 --- /dev/null +++ b/src/lib/openExternalLink.ts @@ -0,0 +1,21 @@ +// 서비스 밖 주소(토스 딥링크, K-스마트멘토, 학생회 카카오 채널 등)를 여는 자리. 화면은 이 함수만 +// 부르고, 여는 방식이 바뀌면 여기만 고친다. +// +// **앱 WebView에서는 이 구현이 맞지 않을 수 있다.** 앱 안에서 `window.open`은 막히거나 +// (아무 일도 안 일어남) WebView 안에서 그대로 열려서 사용자가 외부 페이지에 갇힌다 — 앱에는 +// 주소창도 뒤로가기도 없다. 제대로 하려면 앱 셸에 "이 주소를 시스템 브라우저로 열어 달라"고 +// 넘겨야 한다. 재료는 이미 `lib/bridge/`에 있다. +// +// if (isInAppShell()) { +// postBridgeMessage(OPEN_EXTERNAL_LINK_MESSAGE_TYPE, { url }); +// return; +// } +// +// 지금 넣지 않은 이유: 브리지 메시지는 **앱 쪽에 받는 핸들러가 같이 들어가야** 동작한다 +// (`bridge.ts` 주석의 메시지 추가 절차 3단계). 웹에만 넣으면 앱이 모르는 메시지로 조용히 +// 버려서 지금과 결과가 같다. 게다가 연결할 주소들이 아직 비어 있어서 급할 것도 없다. +// +// 주소와 앱 작업이 준비되면 위 분기를 여기에 채운다 — 호출부는 건드릴 필요가 없다. +export function openExternalLink(url: string): void { + window.open(url, "_blank", "noopener,noreferrer"); +} diff --git a/src/lib/useFunnel.ts b/src/lib/useFunnel.ts index 918970f..f79c646 100644 --- a/src/lib/useFunnel.ts +++ b/src/lib/useFunnel.ts @@ -1,7 +1,9 @@ import { createUseFunnel } from "@use-funnel/react-router-dom"; -import { useMemo } from "react"; +import { useCallback, useMemo } from "react"; import { useLocation, useNavigate, useSearchParams } from "react-router-dom"; +import { getHistoryIndex } from "@/lib/historyIndex"; + interface FunnelRouteOption { preventScrollReset?: boolean; flushSync?: boolean; @@ -125,3 +127,32 @@ export const useFunnel = createUseFunnel( ); }, ); + +/** + * 퍼널을 빠져나가는 이동. 퍼널이 쌓아 둔 단계 수(`index + 1`)만큼 한 번에 되돌아가서, 나간 뒤 + * 브라우저 뒤로가기를 눌러도 단계가 되살아나지 않게 한다. 퍼널로 바로 들어와(딥링크·앱 WebView + * 진입·주소 직접 입력) 되돌아갈 기록이 없으면 `fallbackPath`로 치환한다. + * + * `navigate(fallbackPath, { replace: true })`만으로는 안 된다 — 지금 단계만 치환해서 앞 단계들이 + * 앞으로가기 기록이 아니라 **뒤로가기** 기록에 그대로 남는다. 플로우를 끝내고 나간 뒤 뒤로가기를 + * 누르면 중간 단계가 context째 되살아나서 같은 흐름을 다시 탈 수 있다. + * + * 단계 수는 호출하는 쪽이 넘긴다 — `funnel.Render`의 단계별 `index`를 그대로 주면 된다. + * `history.replace`로 단계를 바꾼 경우(완료 단계)는 히스토리가 늘지 않아 `index`도 그대로다. + */ +export function useFunnelExit(fallbackPath: string) { + const navigate = useNavigate(); + + return useCallback( + (stepIndex: number) => { + const stepCount = stepIndex + 1; + const historyIndex = getHistoryIndex(); + if (historyIndex !== null && historyIndex - stepCount >= 0) { + navigate(-stepCount); + return; + } + navigate(fallbackPath, { replace: true }); + }, + [navigate, fallbackPath], + ); +}