diff --git a/docs/conventions/component-convention.md b/docs/conventions/component-convention.md index 1a459b7..16c6996 100644 --- a/docs/conventions/component-convention.md +++ b/docs/conventions/component-convention.md @@ -72,6 +72,7 @@ interface RentalItemCardProps { - **예외**: 서드파티 라이브러리가 `className` 문자열만 받아서 자기 DOM에 그대로 꽂는 자리(예: `@ncdai/react-wheel-picker`의 `classNames` prop)는 `Typography`로 감쌀 수 없다 — 이럴 땐 Tailwind `text-[17px]` 같은 값을 그대로 쓰되, 어느 Figma 타입 스타일을 옮긴 값인지 주석을 남긴다(`BililgeRentalSheet.tsx`의 `WHEEL_CLASS_NAMES` 참고). - Stream 자체 이미지·아이콘(일러스트, 물품 아이콘 등)은 `download_assets`로 받아 `src/assets/`에 커밋한다. Figma asset URL은 **7일 후 만료**되므로 절대 코드에 그대로 참조하지 않는다. +- 에셋은 쓰는 기능 이름의 폴더에 둔다(`src/assets/icons/<기능>/`, `src/assets/lottie/<기능>/`). 두 기능 이상이 같이 쓰게 되면 `common/`으로 옮긴다(예: 행사·사물함 신청 결과 화면의 `icons/common/document-lock.svg`) — 컴포넌트를 `components/ui/`로 옮기는 기준과 같다. ### 모션 (Lottie) @@ -79,7 +80,7 @@ Figma Motion(키프레임 타임라인)이 붙은 요소는 키프레임을 손 - **export**: Figma Dev mode에는 Lottie export가 없다. LottieFiles 플러그인에서 **키프레임이 붙은 프레임을 선택**하고 Export 탭 → Preview 확인 후 json으로 받는다. 화면 전체 프레임이 아니라 모션 프레임을 골라야 한다(예: `행사 로딩 1133:43453`이 아니라 `Loading / Document Review 1133:44260`). - hidden 레이어와 boolean 연산(union/intersect/exclude)은 export에서 빠진다. Preview에 안 보이면 이걸 먼저 의심한다. -- **파일 위치**: `src/assets/lottie/<기능>/<이름>.json`. 이미지·아이콘과 같은 이유로 저장소에 커밋한다. +- **파일 위치**: `src/assets/lottie/<기능>/<이름>.json`, 두 기능 이상이 쓰면 `src/assets/lottie/common/`. 이미지·아이콘과 같은 이유로 저장소에 커밋한다. - **컴포넌트**: `lottie-react`의 **`LottieLight`**를 쓴다. `Lottie`(풀 빌드)는 canvas·HTML 렌더러와 `eval` 기반 표현식 엔진까지 끌고 와서 번들이 224KB(gzip 43KB) 커지고, 출하 번들에 `eval`이 남아 WebView CSP에서 깨질 수 있다. Figma에서 나온 Lottie는 표현식을 쓰지 않으므로 SVG 렌더러만 있으면 된다. - **문구는 Lottie에 넣지 않는다**: export 원본에 텍스트가 포함돼 있으면 그 레이어를 빼고 컴포지션을 일러스트 경계로 자른 뒤, 문구는 `Typography`로 따로 그린다. Lottie 안의 텍스트는 벡터 도형이라 스크린리더가 못 읽고 타이포 토큰도 안 따라간다. **어떤 레이어를 왜 뺐는지와 잘라낸 크기를 컴포넌트 주석에 남긴다** — 재export 때 같은 가공을 다시 해야 한다. - **모션 줄이기**: `usePrefersReducedMotion()`으로 판단해 `autoplay={false}` + `segment={[n, n + 1]}`로 다 그려진 프레임 한 장만 세운다. `autoplay`를 켠 채로 두면 lottie-react가 알아서 막긴 하지만 개발 콘솔에 경고를 남기므로 우리가 먼저 끈다. CSS transition으로 끌 수 있는 자리(`ConfirmModal` 등)는 Tailwind `motion-reduce:` 변형을 그대로 쓴다. diff --git a/docs/conventions/wds-component-usage.md b/docs/conventions/wds-component-usage.md index b1f2d21..8991d99 100644 --- a/docs/conventions/wds-component-usage.md +++ b/docs/conventions/wds-component-usage.md @@ -315,17 +315,17 @@ Figma 상세는 뒤로가기 버튼이 Hero 이미지 **위에 떠 있는** 오 `IconCircleCheckFill` 같은 WDS 아이콘이 아니다 — 72px 프레임 안에 60px `Primary/Normal` 원과 흰 체크 선이 따로 있고, 진입할 때 원이 튀어오르며 커지고(back-out) 체크 선이 그려진다(path trim). `get_design_context`의 Component description도 "System Check"뿐이고 montage 문서 링크가 없어서 WDS가 아닌 게 확정된다. -모션은 `src/assets/lottie/events/complete-check.json`(LottieFiles 플러그인 export)을 `LottieLight`로 재생한다 — 처음엔 `get_motion_context` 값을 보고 SVG path와 키프레임을 손으로 옮겼지만, 디자이너가 모션을 고칠 때마다 같은 노동이 반복돼서 Lottie로 바꿨다(`component-convention.md` "모션 (Lottie)" 참고). 코드는 `src/features/events/components/EventsCompleteCheck.tsx`. +모션은 `src/assets/lottie/common/complete-check.json`(LottieFiles 플러그인 export)을 `LottieLight`로 재생한다 — 처음엔 `get_motion_context` 값을 보고 SVG path와 키프레임을 손으로 옮겼지만, 디자이너가 모션을 고칠 때마다 같은 노동이 반복돼서 Lottie로 바꿨다(`component-convention.md` "모션 (Lottie)" 참고). 코드는 `src/components/ui/CompleteCheck.tsx`(사물함 신청 완료와 공용). ### 행사 신청 중 마감 화면(`1133:43431`)도 같은 뼈대다 -완료 화면과 구조가 같다 — 닫기(X)만 있는 `TopNavigation`, 그 아래 104px 간격, 가운데 일러스트 + 2줄 문구, 하단 Action Area. 다른 점은 버튼이 하나라서 `ActionArea`를 기본값(`variant="strong"`, 세로 배치)으로 쓰고 신청 폼과 같은 `sx={{ paddingBlock: "16px" }}` 보정만 한다는 것뿐이다. 자물쇠 일러스트(`1133:43439`)는 WDS 아이콘이 아니라 Figma 로컬 도형이라 SVG를 그대로 받아 `src/assets/icons/events/application-closed.svg`로 커밋했다(62.963×72.317). +완료 화면과 구조가 같다 — 닫기(X)만 있는 `TopNavigation`, 그 아래 104px 간격, 가운데 일러스트 + 2줄 문구, 하단 Action Area. 다른 점은 버튼이 하나라서 `ActionArea`를 기본값(`variant="strong"`, 세로 배치)으로 쓰고 신청 폼과 같은 `sx={{ paddingBlock: "16px" }}` 보정만 한다는 것뿐이다. 자물쇠 일러스트(`1133:43439`)는 WDS 아이콘이 아니라 Figma 로컬 도형이라 SVG를 그대로 받아 `src/assets/icons/common/document-lock.svg`로 커밋했다(62.963×72.317). 사물함 신청 오류 화면도 같은 그림이라 공용 폴더에 둔다. 이 화면 구조는 지금 공용 `ResultScreen`이 갖는다. ### 제출 중 로딩 화면(`1133:43453`)에서 WDS는 `Typography`뿐이다 -문서 일러스트와 체크 항목 3줄은 전부 Figma 로컬 도형이고(WDS 아이콘 아님), 3.4초 루프 모션이 붙어 있다(`Loading / Document Review`, 1133:44260). 이 일러스트 전체를 `src/assets/lottie/events/submitting.json`으로 받아 `LottieLight`로 재생한다 — 처음엔 path trim을 SVG로 인라인하고 문서 본체만 svg로 받았지만 Lottie로 바꿨다(`component-convention.md` "모션 (Lottie)" 참고). +문서 일러스트와 체크 항목 3줄은 전부 Figma 로컬 도형이고(WDS 아이콘 아님), 3.4초 루프 모션이 붙어 있다(`Loading / Document Review`, 1133:44260). 이 일러스트 전체를 `src/assets/lottie/common/submitting.json`으로 받아 `LottieLight`로 재생한다 — 처음엔 path trim을 SVG로 인라인하고 문서 본체만 svg로 받았지만 Lottie로 바꿨다(`component-convention.md` "모션 (Lottie)" 참고). -LottieFiles export 원본(`Loading Content`)에는 문구 2줄도 벡터 도형으로 들어 있는데 그 레이어는 빼고 쓴다 — 문구 2줄은 WDS `Typography`(`heading1` 22px / `label1` 14px)로 그려야 스크린리더가 읽고 타이포 토큰도 따라간다. 코드는 `src/features/events/components/EventsSubmittingOverlay.tsx`. +LottieFiles export 원본(`Loading Content`)에는 문구 2줄도 벡터 도형으로 들어 있는데 그 레이어는 빼고 쓴다 — 문구 2줄은 WDS `Typography`(`heading1` 22px / `label1` 14px)로 그려야 스크린리더가 읽고 타이포 토큰도 따라간다. 코드는 `src/components/ui/SubmittingOverlay.tsx`(사물함 신청 중과 공용, 문구는 prop). ## 공지 상세 화면(`1256:81842`, `1256:81856`) 구현 중 확정된 매핑 @@ -528,3 +528,24 @@ A-1 본문의 `Zone Grid Row`(`2159:110823`)가 `h-px`라 `위쪽 칸/아래쪽 ### 실제 사진의 칸 번호는 서버 사진에 찍혀 온다 처음엔 서버가 사진 주소만 주고 프론트가 구역별 핀 좌표로 번호를 얹었지만, 사진을 바꿀 때마다 좌표를 같이 고쳐야 해서 번호를 사진에 찍어 받는 걸로 바꿨다. 모달은 `photoUrl`을 Figma 사진 틀(300×224)에 그대로 채운다. Figma의 사진 회전(1.12°)·확대 보정과 번호 핀(Orange 계열)은 사진을 만들 때 반영한다. + +## 사물함 신청 확인·진행·완료·오류(`1737:218322`, `1737:218076`, `1737:218058`, `3013:99093` 외 4종) 구현 중 확정된 매핑 + +| Figma | 코드 | 확인 내용 | +|---|---|---| +| 사물함 선택 확인 모달 (`1737:218322`) | `ConfirmModal` + `highlight` | 행사 신청 확인 모달과 같은 Stream 로컬 Modal이다. 사물함 번호만 Primary 색이라 `highlight`로 넘긴다 | +| 사물함 신청 로딩 (`1737:218076`) | `SubmittingOverlay` | 행사 제출 중 화면과 같은 모션, 문구만 다르다 | +| 사물함 신청 완료 (`1737:218058`) | `ResultScreen` + `CompleteCheck` | 행사 완료 화면과 같은 뼈대. `Locker Summary` 카드(`1737:218070`)만 화면이 그린다 | +| 오류 5종 (`3013:99093`·`99116`·`99139`·`99162`·`99183`) | `ResultScreen` | 행사 신청 중 마감 화면과 같은 뼈대. 버튼 1개면 세로, 2개면 `neutral` | + +### 결과 화면은 공용 `ResultScreen` — WDS `FallbackView`를 쓰지 않는다 + +행사 완료·마감, 사물함 완료·오류 7개 화면이 닫기(X) 헤더, 104px 아래 일러스트 + 2줄 문구, 하단 Action Area로 같은 뼈대라 `src/components/ui/ResultScreen.tsx`로 모았다. WDS `FallbackView`도 구조는 같지만 Empty State 때와 같은 이유(일러스트 폭·상하 패딩·컨테이너 폭이 고정)로 맞지 않아서, 바깥 틀만 로컬로 짜고 내부는 WDS(`TopNavigation`, `Typography`, `ActionArea`)로 채운다. 그림과 문구 사이 간격은 Figma가 체크 모션 화면만 8px, 나머지는 16px이라 `illustrationGap`으로 받는다. + +### 오류 일러스트 — 자물쇠는 공용, 와이파이는 사물함 로컬 + +자물쇠(`document-lock.svg`)는 행사 마감 화면과 같은 그림이라 `icons/common/`의 것을 쓴다. 네트워크 오류의 와이파이 끊김 그림(`3013:99162`)은 WDS 아이콘이 아닌 Figma 로컬 도형이라 `icons/lockers/network-offline.svg`로 받았다 — 75×59.88 그림을 Figma 80×80 틀 위치 그대로 svg 안에 넣었다. + +### 미해결: 버튼 2개의 문구 길이가 다르면 너비가 반반이 안 된다 + +`ActionArea variant="neutral"`은 버튼을 `flex: 1 1 0`으로 나누지만, 좌우 padding 28px + 문구가 절반 너비를 넘으면 그 버튼이 넓어진다. 구역 마감 화면(`3013:99139`)의 `다른 구역 선택하기`가 그렇다(Figma는 반반). 맞추려면 `ResultScreen`의 버튼 좌우 padding을 줄여야 해서 보류했다. diff --git a/src/app/router.tsx b/src/app/router.tsx index c4a70a7..0f39237 100644 --- a/src/app/router.tsx +++ b/src/app/router.tsx @@ -16,6 +16,8 @@ import FeedbacksDetailScreen from "@/features/feedbacks/FeedbacksDetailScreen"; import FeedbacksListScreen from "@/features/feedbacks/FeedbacksListScreen"; import FeedbacksNewScreen from "@/features/feedbacks/FeedbacksNewScreen"; import HomeScreen from "@/features/home/HomeScreen"; +import LockersApplyCompleteScreen from "@/features/lockers/LockersApplyCompleteScreen"; +import LockersApplyFailureScreen from "@/features/lockers/LockersApplyFailureScreen"; import LockersApplyScreen from "@/features/lockers/LockersApplyScreen"; import LockersLockerSelectScreen from "@/features/lockers/LockersLockerSelectScreen"; import LockersSectionSelectScreen from "@/features/lockers/LockersSectionSelectScreen"; @@ -149,6 +151,24 @@ const routes = [ } satisfies ScreenRouteHandle, path: "/lockers/apply/sections/:sectionId", }, + { + element: , + // 신청 결과 화면 — 행사 신청 결과와 같이 하단 탭 없이 흰 배경 전체 화면이다 + handle: { + background: "normal", + hasBottomNav: false, + } satisfies ScreenRouteHandle, + path: "/lockers/apply/sections/:sectionId/complete", + }, + { + element: , + // 신청 결과 화면 — 완료 화면과 같다 + handle: { + background: "normal", + hasBottomNav: false, + } satisfies ScreenRouteHandle, + path: "/lockers/apply/sections/:sectionId/failure/:reason", + }, // 라우트가 없는 경로 — 레이아웃 안에 둬서 하단 탭이 유지되고, 탭 경로(/event 등)면 그 탭이 활성으로 보인다 { element: , diff --git a/src/assets/icons/events/application-closed.svg b/src/assets/icons/common/document-lock.svg similarity index 100% rename from src/assets/icons/events/application-closed.svg rename to src/assets/icons/common/document-lock.svg diff --git a/src/assets/icons/lockers/network-offline.svg b/src/assets/icons/lockers/network-offline.svg new file mode 100644 index 0000000..36f5979 --- /dev/null +++ b/src/assets/icons/lockers/network-offline.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/src/assets/lottie/events/complete-check.json b/src/assets/lottie/common/complete-check.json similarity index 100% rename from src/assets/lottie/events/complete-check.json rename to src/assets/lottie/common/complete-check.json diff --git a/src/assets/lottie/events/submitting.json b/src/assets/lottie/common/submitting.json similarity index 100% rename from src/assets/lottie/events/submitting.json rename to src/assets/lottie/common/submitting.json diff --git a/src/features/events/components/EventsCompleteCheck.tsx b/src/components/ui/CompleteCheck.tsx similarity index 87% rename from src/features/events/components/EventsCompleteCheck.tsx rename to src/components/ui/CompleteCheck.tsx index 6c0dd3f..45f7613 100644 --- a/src/features/events/components/EventsCompleteCheck.tsx +++ b/src/components/ui/CompleteCheck.tsx @@ -1,6 +1,6 @@ import { LottieLight } from "lottie-react"; -import completeCheck from "@/assets/lottie/events/complete-check.json"; +import completeCheck from "@/assets/lottie/common/complete-check.json"; import { usePrefersReducedMotion } from "@/hooks/usePrefersReducedMotion"; // Figma: Circle Check Motion (nodeId 1712:192849), 완료 화면에 놓인 인스턴스는 1712:192851. @@ -13,7 +13,8 @@ import { usePrefersReducedMotion } from "@/hooks/usePrefersReducedMotion"; // 모션 줄이기를 켠 사용자에게는 다 그려진 한 프레임만 세워둔다. 33프레임에서 체크가 완성된다. const DRAWN_SEGMENT = [33, 34] as const; -function EventsCompleteCheck() { +// 신청 완료 화면(행사·사물함 공용)의 체크 모션 +function CompleteCheck() { const shouldReduceMotion = usePrefersReducedMotion(); return ( @@ -30,4 +31,4 @@ function EventsCompleteCheck() { ); } -export default EventsCompleteCheck; +export default CompleteCheck; diff --git a/src/components/ui/ResultScreen.tsx b/src/components/ui/ResultScreen.tsx new file mode 100644 index 0000000..9b9e8ae --- /dev/null +++ b/src/components/ui/ResultScreen.tsx @@ -0,0 +1,132 @@ +import { + ActionArea, + ActionAreaButton, + TopNavigationButton, + Typography, +} from "@wanteddev/wds"; +import { IconClose } from "@wanteddev/wds-icon"; +import type { ReactNode } from "react"; + +import ScreenHeader from "@/components/ui/ScreenHeader"; +import { useScreenHeader } from "@/components/ui/useScreenHeader"; + +interface ResultScreenAction { + label: string; + onClick: () => void; +} + +// 그림과 문구 사이 간격. Figma가 마감·오류 화면은 16px, 완료 화면(체크 모션)은 8px로 잡아뒀다. +type ResultScreenIllustrationGap = 8 | 16; + +const ILLUSTRATION_GAP_CLASS_NAMES: Record< + ResultScreenIllustrationGap, + string +> = { + 8: "gap-2", + 16: "gap-4", +}; + +interface ResultScreenProps { + illustration: ReactNode; + illustrationGap?: ResultScreenIllustrationGap; + title: string; + description: string; + /** 문구 아래에 붙는 내용(완료 화면의 신청 요약 카드 등) */ + children?: ReactNode; + primaryAction: ResultScreenAction; + /** 있으면 두 버튼을 반반 나란히 두고, 이 버튼을 왼쪽(회색)에 둔다 */ + secondaryAction?: ResultScreenAction; + onClose: () => void; +} + +// 신청 결과 화면(완료·마감·오류) 공용 — 행사·사물함 신청이 같이 쓴다. +// Figma: 행사 신청 중 마감됨 (nodeId 1133:43431), 사물함 신청 오류 (3013:99093 외) +function ResultScreen({ + illustration, + illustrationGap = 16, + title, + description, + children, + primaryAction, + secondaryAction, + onClose, +}: ResultScreenProps) { + useScreenHeader( + + + + } + variant="normal" + />, + ); + + return ( +
+
+
+ {illustration} +
+ + {title} + + + {description} + +
+
+ {children} +
+ +
+ {/* WDS는 버튼 세로 padding을 12px(48px)로 주는데 Figma Main Action은 16px(56px)이라 맞춘다 */} + {secondaryAction ? ( + + + {secondaryAction.label} + + + {primaryAction.label} + + + ) : ( + + + {primaryAction.label} + + + )} + {/* ActionArea 아래 padding 20px + 14px = Figma Bottom Safe Area 34px. 앱 WebView에서는 + 네이티브 세이프에어리어와 겹쳐서 데스크톱 프레임에서만 남긴다. */} +
+
+
+ ); +} + +export default ResultScreen; diff --git a/src/features/events/components/EventsSubmittingOverlay.tsx b/src/components/ui/SubmittingOverlay.tsx similarity index 80% rename from src/features/events/components/EventsSubmittingOverlay.tsx rename to src/components/ui/SubmittingOverlay.tsx index 2969e93..d02df56 100644 --- a/src/features/events/components/EventsSubmittingOverlay.tsx +++ b/src/components/ui/SubmittingOverlay.tsx @@ -2,11 +2,11 @@ import { Typography } from "@wanteddev/wds"; import { LottieLight } from "lottie-react"; import { createPortal } from "react-dom"; -import submittingDocument from "@/assets/lottie/events/submitting.json"; +import submittingDocument from "@/assets/lottie/common/submitting.json"; import { useScreenSheetPortal } from "@/components/ui/useScreenSheetPortal"; import { usePrefersReducedMotion } from "@/hooks/usePrefersReducedMotion"; -// Figma: 행사 로딩 (nodeId 1133:43453), 모션은 Loading / Document Review (1133:44260). +// Figma: 행사 로딩 (nodeId 1133:43453), 사물함 신청 로딩 (1737:218076), 모션은 Loading / Document Review (1133:44260). // 3.4초 루프 안에서 체크·긴 줄·짧은 줄이 순서대로 그려지고 마지막에 함께 사라진다. // // 모션은 Figma LottieFiles 플러그인으로 뽑은 Lottie를 그대로 재생한다 — 자세한 규칙은 @@ -21,8 +21,18 @@ import { usePrefersReducedMotion } from "@/hooks/usePrefersReducedMotion"; // 173프레임에 모든 선이 완성되고 181프레임부터 함께 사라지기 시작한다. const DRAWN_SEGMENT = [175, 176] as const; -// 제출 중 화면 — Figma에 헤더가 없어서 화면 포털에 흰 배경으로 프레임 전체를 덮는다. -function EventsSubmittingOverlay({ open }: { open: boolean }) { +interface SubmittingOverlayProps { + open: boolean; + title: string; + description: string; +} + +// 신청 제출 중 화면(행사·사물함 공용) — Figma에 헤더가 없어서 화면 포털에 흰 배경으로 프레임 전체를 덮는다. +function SubmittingOverlay({ + open, + title, + description, +}: SubmittingOverlayProps) { const portalEl = useScreenSheetPortal(); const shouldReduceMotion = usePrefersReducedMotion(); @@ -50,7 +60,7 @@ function EventsSubmittingOverlay({ open }: { open: boolean }) { variant="heading1" weight="bold" > - 신청서를 제출하고 있어요 + {title} - 곧 완료돼요! 잠시만 기다려 주세요 + {description}
, @@ -66,4 +76,4 @@ function EventsSubmittingOverlay({ open }: { open: boolean }) { ); } -export default EventsSubmittingOverlay; +export default SubmittingOverlay; diff --git a/src/features/events/EventsApplicationClosedScreen.tsx b/src/features/events/EventsApplicationClosedScreen.tsx index 975c0dd..7226389 100644 --- a/src/features/events/EventsApplicationClosedScreen.tsx +++ b/src/features/events/EventsApplicationClosedScreen.tsx @@ -1,79 +1,23 @@ -import { - ActionArea, - ActionAreaButton, - TopNavigationButton, - Typography, -} from "@wanteddev/wds"; -import { IconClose } from "@wanteddev/wds-icon"; import { useNavigate } from "react-router-dom"; -import applicationClosed from "@/assets/icons/events/application-closed.svg"; -import ScreenHeader from "@/components/ui/ScreenHeader"; -import { useScreenHeader } from "@/components/ui/useScreenHeader"; +import documentLock from "@/assets/icons/common/document-lock.svg"; +import ResultScreen from "@/components/ui/ResultScreen"; // Figma: 행사 신청 중 마감됨 (nodeId 1133:43431) // 제출하는 사이에 인원이 다 찬 경우다 — 신청 결과 화면이라 신청서로는 돌아가지 않는다. function EventsApplicationClosedScreen() { const navigate = useNavigate(); - useScreenHeader( - navigate("/")} - variant="icon" - > - - - } - variant="normal" - />, - ); - return ( -
-
- -
- - 신청 인원이 모두 찼어요 - - - 다음 행사에서 만나요! - -
-
- -
- {/* 버튼 하나 짜리 Action Area — 신청 폼과 같은 이유로 세로 padding만 Figma(16px, 56px)에 맞춘다 */} - - navigate("/")} - sx={{ paddingBlock: "16px" }} - > - 홈으로 가기 - - - {/* WDS ActionArea의 아래 padding 20px + 여기 14px = Figma Bottom Safe Area 34px. - 앱 WebView에서는 네이티브 세이프에어리어와 중복이라 데스크톱 프레임에서만 남긴다. */} -
-
-
+ + } + onClose={() => navigate("/")} + primaryAction={{ label: "홈으로 가기", onClick: () => navigate("/") }} + title="신청 인원이 모두 찼어요" + /> ); } diff --git a/src/features/events/EventsApplicationCompleteScreen.tsx b/src/features/events/EventsApplicationCompleteScreen.tsx index 3d3cd37..f886908 100644 --- a/src/features/events/EventsApplicationCompleteScreen.tsx +++ b/src/features/events/EventsApplicationCompleteScreen.tsx @@ -1,15 +1,7 @@ -import { - ActionArea, - ActionAreaButton, - TopNavigationButton, - Typography, -} from "@wanteddev/wds"; -import { IconClose } from "@wanteddev/wds-icon"; import { useNavigate } from "react-router-dom"; -import ScreenHeader from "@/components/ui/ScreenHeader"; -import { useScreenHeader } from "@/components/ui/useScreenHeader"; -import EventsCompleteCheck from "@/features/events/components/EventsCompleteCheck"; +import CompleteCheck from "@/components/ui/CompleteCheck"; +import ResultScreen from "@/components/ui/ResultScreen"; import EventsSummaryCard from "@/features/events/components/EventsSummaryCard"; import { EVENTS_APPLICATION } from "@/features/events/constants/eventsApplication"; @@ -22,79 +14,27 @@ function EventsApplicationCompleteScreen() { const navigate = useNavigate(); const { eventName, dateTime, location } = EVENTS_APPLICATION; - useScreenHeader( - navigate("/")} - variant="icon" - > - - - } - variant="normal" - />, - ); - return ( -
-
-
- -
- - 신청이 완료됐어요 - - - 신청해 주셔서 감사해요. 행사날 뵐게요! - -
-
- -
- -
- {/* variant="neutral" — 버튼 둘을 가로로 12px 간격에 반반씩 두는 Figma Action Area와 같다. - WDS는 버튼 세로 padding을 12px(48px)로 주는데 Figma Main Action은 16px(56px)이라 맞춘다. */} - - navigate(APPLICATION_HISTORY_PATH)} - sx={{ padding: "16px 28px" }} - variant="alternative" - > - 신청내역 보기 - - navigate("/")} - sx={{ padding: "16px 28px" }} - > - 홈으로 가기 - - - {/* 신청 폼과 같은 이유의 14px — WDS ActionArea의 아래 padding 20px에 iOS Home Bar 여백을 더해 34px. - 앱 WebView에서는 네이티브 세이프에어리어와 중복이라 데스크톱 프레임에서만 남긴다. */} -
-
-
+ } + illustrationGap={8} + // 신청이 끝난 화면이라 닫으면 폼으로 돌아가지 않고 홈으로 나간다 + onClose={() => navigate("/")} + primaryAction={{ label: "홈으로 가기", onClick: () => navigate("/") }} + secondaryAction={{ + label: "신청내역 보기", + onClick: () => navigate(APPLICATION_HISTORY_PATH), + }} + title="신청이 완료됐어요" + > + + ); } diff --git a/src/features/events/EventsApplicationScreen.tsx b/src/features/events/EventsApplicationScreen.tsx index a2c9c1b..93c7815 100644 --- a/src/features/events/EventsApplicationScreen.tsx +++ b/src/features/events/EventsApplicationScreen.tsx @@ -10,9 +10,9 @@ import { useNavigate, useParams } from "react-router-dom"; import ConfirmModal from "@/components/ui/ConfirmModal"; import ScreenHeader from "@/components/ui/ScreenHeader"; import ScreenToast from "@/components/ui/ScreenToast"; +import SubmittingOverlay from "@/components/ui/SubmittingOverlay"; import { useScreenHeader } from "@/components/ui/useScreenHeader"; import EventsQuestionField from "@/features/events/components/EventsQuestionField"; -import EventsSubmittingOverlay from "@/features/events/components/EventsSubmittingOverlay"; import EventsSummaryCard from "@/features/events/components/EventsSummaryCard"; import { EVENTS_APPLICATION, @@ -183,7 +183,11 @@ function EventsApplicationScreen() { tone="negative" /> - + ; + } + const { lockerLabel, usageStartDate, usageEndDate } = state.application; + + return ( + } + illustrationGap={8} + onClose={() => navigate("/")} + primaryAction={{ label: "홈으로 가기", onClick: () => navigate("/") }} + secondaryAction={{ + label: "신청내역 보기", + onClick: () => navigate(APPLICATION_HISTORY_PATH), + }} + title="신청이 완료됐어요" + > + {/* Figma: Locker Summary (nodeId 1737:218070) */} +
+ + + {lockerLabel} + {" "} + 사물함 + +
+ + 사용기간 + + + {formatUsageDate(usageStartDate)} - {formatUsageDate(usageEndDate)} + +
+
+
+ ); +} + +export default LockersApplyCompleteScreen; diff --git a/src/features/lockers/LockersApplyFailureScreen.tsx b/src/features/lockers/LockersApplyFailureScreen.tsx new file mode 100644 index 0000000..80871db --- /dev/null +++ b/src/features/lockers/LockersApplyFailureScreen.tsx @@ -0,0 +1,153 @@ +import type { ReactNode } from "react"; +import { + Navigate, + type NavigateFunction, + useLocation, + useNavigate, + useParams, +} from "react-router-dom"; + +import documentLock from "@/assets/icons/common/document-lock.svg"; +import networkOffline from "@/assets/icons/lockers/network-offline.svg"; +import ResultScreen from "@/components/ui/ResultScreen"; +import SubmittingOverlay from "@/components/ui/SubmittingOverlay"; +import { + LOCKERS_APPLY_SUBMITTING_TEXT, + type LockersApplyFailureReason, +} from "@/features/lockers/constants/lockersApplySubmit"; +import { + type LockersApplyFailureState, + useLockersApplySubmit, +} from "@/features/lockers/hooks/useLockersApplySubmit"; + +// Figma는 자물쇠 그림(62.96×72.32)을 80×80 틀 가운데에 둔다 +const LOCK_ILLUSTRATION = ( +
+ +
+); + +interface FailureContentContext { + sectionId: string; + navigate: NavigateFunction; + retry: () => void; +} + +interface FailureContent { + illustration: ReactNode; + title: string; + description: string; + primaryAction: { label: string; onClick: () => void }; + hasHomeAction: boolean; + // 다시 시도할 사물함이 있어야 화면이 동작한다(network·server) + needsRetryRequest?: boolean; +} + +// Figma: 다른 사용자와 겹침 (nodeId 3013:99093), 전체 마감 (3013:99116), 구역 마감 (3013:99139), +// 네트워크 오류 (3013:99162), 서버 오류 (3013:99183) +const FAILURE_CONTENTS: Record< + LockersApplyFailureReason, + (context: FailureContentContext) => FailureContent +> = { + "all-closed": ({ navigate }) => ({ + description: "다음 기회에 다시 이용해 주세요.", + hasHomeAction: false, + illustration: LOCK_ILLUSTRATION, + primaryAction: { label: "홈으로 가기", onClick: () => navigate("/") }, + title: "사물함 신청이 모두 마감됐어요", + }), + network: ({ retry }) => ({ + description: "연결 상태를 확인한 후 다시 시도해 주세요.", + hasHomeAction: true, + illustration: , + needsRetryRequest: true, + primaryAction: { label: "다시 시도", onClick: retry }, + title: "인터넷 연결이 원활하지 않아요", + }), + "section-closed": ({ sectionId, navigate }) => ({ + description: "다른 구역의 사물함을 확인해 보세요.", + hasHomeAction: true, + illustration: LOCK_ILLUSTRATION, + primaryAction: { + label: "다른 구역 선택하기", + onClick: () => navigate("/lockers/apply/sections", { replace: true }), + }, + title: `${sectionId} 구역의 사물함이 마감됐어요`, + }), + server: ({ retry }) => ({ + description: "잠시 후 다시 시도해 주세요.", + hasHomeAction: true, + illustration: LOCK_ILLUSTRATION, + needsRetryRequest: true, + primaryAction: { label: "다시 시도", onClick: retry }, + title: "일시적인 오류가 발생했어요", + }), + taken: ({ sectionId, navigate }) => ({ + description: "다른 사물함으로 다시 신청해 주세요.", + hasHomeAction: true, + illustration: LOCK_ILLUSTRATION, + primaryAction: { + label: "다시 선택하기", + onClick: () => + navigate(`/lockers/apply/sections/${sectionId}`, { replace: true }), + }, + title: "선택한 사물함이 방금 마감됐어요", + }), +}; + +function isFailureReason( + reason: string | undefined, +): reason is LockersApplyFailureReason { + // in은 constructor·toString 같은 기본 속성까지 통과시켜서 직접 정의한 키만 본다 + return reason !== undefined && Object.hasOwn(FAILURE_CONTENTS, reason); +} + +function LockersApplyFailureScreen() { + const navigate = useNavigate(); + const { sectionId = "", reason } = useParams(); + const state = useLocation().state as LockersApplyFailureState | null; + const { isSubmitting, submit } = useLockersApplySubmit(sectionId); + + if (!isFailureReason(reason)) { + return ; + } + + const content = FAILURE_CONTENTS[reason]({ + navigate, + retry: () => { + if (state?.request) { + submit(state.request); + } + }, + sectionId, + }); + + // 신청 직후에만 오는 화면이라, 주소로 바로 들어와 다시 신청할 사물함을 모르면 홈으로 보낸다 + if (content.needsRetryRequest && !state?.request) { + return ; + } + + return ( + <> + navigate("/")} + primaryAction={content.primaryAction} + secondaryAction={ + content.hasHomeAction + ? { label: "홈으로 가기", onClick: () => navigate("/") } + : undefined + } + title={content.title} + /> + + + ); +} + +export default LockersApplyFailureScreen; diff --git a/src/features/lockers/LockersLockerSelectScreen.tsx b/src/features/lockers/LockersLockerSelectScreen.tsx index f23de7c..f83a443 100644 --- a/src/features/lockers/LockersLockerSelectScreen.tsx +++ b/src/features/lockers/LockersLockerSelectScreen.tsx @@ -4,7 +4,9 @@ import { useCallback, useLayoutEffect, useMemo, useRef, useState } from "react"; import { useLocation, useNavigate, useParams } from "react-router-dom"; import ComingSoonScreen from "@/app/ComingSoonScreen"; +import ConfirmModal from "@/components/ui/ConfirmModal"; import ScreenHeader from "@/components/ui/ScreenHeader"; +import SubmittingOverlay from "@/components/ui/SubmittingOverlay"; import { useScreenHeader } from "@/components/ui/useScreenHeader"; import LockersLayoutRenderer from "@/features/lockers/components/LockersLayoutRenderer"; import LockersLockerLegend from "@/features/lockers/components/LockersLockerLegend"; @@ -13,10 +15,12 @@ import LockersLockerMinimap, { } from "@/features/lockers/components/LockersLockerMinimap"; import LockersSectionPhotoModal from "@/features/lockers/components/LockersSectionPhotoModal"; import LockersSelectedLockerBar from "@/features/lockers/components/LockersSelectedLockerBar"; +import { LOCKERS_APPLY_SUBMITTING_TEXT } from "@/features/lockers/constants/lockersApplySubmit"; import { LOCKERS_SECTION_DETAILS, type LockersSectionDetail, } from "@/features/lockers/constants/lockersSectionDetails"; +import { useLockersApplySubmit } from "@/features/lockers/hooks/useLockersApplySubmit"; import { useLockersPinchZoom } from "@/features/lockers/hooks/useLockersPinchZoom"; // 배치 영역 안쪽 여백(px-5, pb-5) — 미니맵에 보이는 영역을 배치 기준으로 계산할 때 뺀다 @@ -52,6 +56,8 @@ function SectionLockerSelect({ detail }: { detail: LockersSectionDetail }) { number | null >(null); const [isPhotoOpen, setIsPhotoOpen] = useState(false); + const [isConfirmOpen, setIsConfirmOpen] = useState(false); + const { isSubmitting, submit } = useLockersApplySubmit(detail.section); const [minimap, setMinimap] = useState(null); const [scrollerWidth, setScrollerWidth] = useState(); const scrollRef = useRef(null); @@ -200,9 +206,8 @@ function SectionLockerSelect({ detail }: { detail: LockersSectionDetail }) {
{}} lockerLabel={selectedLocker?.lockerLabel ?? null} + onSubmit={() => setIsConfirmOpen(true)} /> + + {/* Figma: 사물함 선택 확인 모달 (nodeId 1737:218322) */} + setIsConfirmOpen(false)} + onConfirm={() => { + setIsConfirmOpen(false); + if (selectedLocker) { + submit({ + lockerId: selectedLocker.lockerId, + lockerLabel: selectedLocker.lockerLabel, + }); + } + }} + open={isConfirmOpen} + title="사물함을 신청할까요?" + /> + +
); } diff --git a/src/features/lockers/constants/lockersApplySubmit.ts b/src/features/lockers/constants/lockersApplySubmit.ts new file mode 100644 index 0000000..40d8d91 --- /dev/null +++ b/src/features/lockers/constants/lockersApplySubmit.ts @@ -0,0 +1,57 @@ +// 신청이 실패한 이유. 오류 화면 주소(`.../failure/:reason`)에 그대로 쓴다. +export type LockersApplyFailureReason = + | "taken" // 다른 사용자가 먼저 신청 (서버 LOCKER_ALREADY_ASSIGNED) + | "all-closed" // 사물함 신청 전체 마감 + | "section-closed" // 고른 구역 마감 + | "network" + | "server"; + +// 성공 값은 POST /v1/app/lockers/applications 응답(LockerApplyResponse)에서 화면에 쓰는 필드만 뽑았다 +export interface LockersApplySuccess { + lockerLabel: string; + usageStartDate: string; + usageEndDate: string; +} + +export type LockersApplySubmitResult = + | { type: "success"; application: LockersApplySuccess } + | { type: "failure"; reason: LockersApplyFailureReason }; + +// 신청 중 화면 문구 — 칸 선택 화면과 오류 화면(다시 시도)이 같이 쓴다. Figma: 사물함 신청 로딩 (1737:218076) +export const LOCKERS_APPLY_SUBMITTING_TEXT = { + description: "곧 완료돼요! 잠시만 기다려 주세요", + title: "사물함 신청을 진행 중이에요", +}; + +export interface LockersApplyRequest { + lockerId: number; + lockerLabel: string; +} + +// 신청 API가 붙기 전까지 쓰는 목업이다. 오류 화면을 확인하려면 이 값을 실패 이유로 바꿔서 실행한다. +const MOCK_OUTCOME: "success" | LockersApplyFailureReason = "success"; +const MOCK_DELAY_MS = 2600; +// 사용 기간은 신청 전 유의사항(lockersNotice.ts)과 같은 목업 값이다 +const MOCK_USAGE_START_DATE = "2026-09-01"; +const MOCK_USAGE_END_DATE = "2026-12-15"; + +export function submitLockersApplication( + request: LockersApplyRequest, +): Promise { + return new Promise((resolve) => { + setTimeout(() => { + resolve( + MOCK_OUTCOME === "success" + ? { + application: { + lockerLabel: request.lockerLabel, + usageEndDate: MOCK_USAGE_END_DATE, + usageStartDate: MOCK_USAGE_START_DATE, + }, + type: "success", + } + : { reason: MOCK_OUTCOME, type: "failure" }, + ); + }, MOCK_DELAY_MS); + }); +} diff --git a/src/features/lockers/hooks/useLockersApplySubmit.ts b/src/features/lockers/hooks/useLockersApplySubmit.ts new file mode 100644 index 0000000..0f89635 --- /dev/null +++ b/src/features/lockers/hooks/useLockersApplySubmit.ts @@ -0,0 +1,69 @@ +import { useEffect, useRef, useState } from "react"; +import { useNavigate } from "react-router-dom"; + +import { + type LockersApplyRequest, + type LockersApplySuccess, + submitLockersApplication, +} from "@/features/lockers/constants/lockersApplySubmit"; + +// 결과 화면으로 넘기는 값. 히스토리에 저장돼 새로고침해도 남고, 주소로 바로 들어오면 없다. +export interface LockersApplyCompleteState { + application: LockersApplySuccess; +} + +export interface LockersApplyFailureState { + // 다시 시도할 때 같은 사물함으로 다시 신청한다 + request: LockersApplyRequest; +} + +// 사물함을 신청하고 결과에 따라 완료·오류 화면으로 보낸다. 칸 선택 화면과 오류 화면의 `다시 시도`가 같이 쓴다. +// 신청을 마친 화면으로는 돌아가지 않게 결과 화면은 replace로 연다. +export function useLockersApplySubmit(sectionId: string) { + const navigate = useNavigate(); + const [isSubmitting, setIsSubmitting] = useState(false); + // 신청 중 화면은 클릭만 막아서 키보드로 버튼을 다시 누를 수 있다 — 진행 중이면 두 번째 신청을 무시한다 + const isSubmittingRef = useRef(false); + // 신청 중에 기기 뒤로가기로 화면을 떠나면, 늦게 온 응답으로 결과 화면에 끌고 가지 않는다 + const isMountedRef = useRef(true); + + useEffect(() => { + isMountedRef.current = true; + return () => { + isMountedRef.current = false; + }; + }, []); + + const submit = async (request: LockersApplyRequest) => { + if (isSubmittingRef.current) { + return; + } + isSubmittingRef.current = true; + setIsSubmitting(true); + const result = await submitLockersApplication(request); + isSubmittingRef.current = false; + if (!isMountedRef.current) { + return; + } + setIsSubmitting(false); + + const basePath = `/lockers/apply/sections/${sectionId}`; + if (result.type === "success") { + navigate(`${basePath}/complete`, { + replace: true, + state: { + application: result.application, + } satisfies LockersApplyCompleteState, + viewTransition: true, + }); + return; + } + navigate(`${basePath}/failure/${result.reason}`, { + replace: true, + state: { request } satisfies LockersApplyFailureState, + viewTransition: true, + }); + }; + + return { isSubmitting, submit }; +}