Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion docs/conventions/component-convention.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,14 +72,15 @@ 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)

Figma Motion(키프레임 타임라인)이 붙은 요소는 키프레임을 손으로 옮기지 않고 **Lottie로 받아 재생한다**. 디자이너가 모션을 고치면 json만 다시 받으면 되기 때문이다. 손으로 옮기면 `times`·이징 값이 코드에 흩어져서 수정 때마다 같은 노동이 반복된다(행사 제출 중·완료 모션이 실제로 그랬다).

- **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:` 변형을 그대로 쓴다.
Expand Down
29 changes: 25 additions & 4 deletions docs/conventions/wds-component-usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`) 구현 중 확정된 매핑

Expand Down Expand Up @@ -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을 줄여야 해서 보류했다.
20 changes: 20 additions & 0 deletions src/app/router.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -149,6 +151,24 @@ const routes = [
} satisfies ScreenRouteHandle,
path: "/lockers/apply/sections/:sectionId",
},
{
element: <LockersApplyCompleteScreen />,
// 신청 결과 화면 — 행사 신청 결과와 같이 하단 탭 없이 흰 배경 전체 화면이다
handle: {
background: "normal",
hasBottomNav: false,
} satisfies ScreenRouteHandle,
path: "/lockers/apply/sections/:sectionId/complete",
},
{
element: <LockersApplyFailureScreen />,
// 신청 결과 화면 — 완료 화면과 같다
handle: {
background: "normal",
hasBottomNav: false,
} satisfies ScreenRouteHandle,
path: "/lockers/apply/sections/:sectionId/failure/:reason",
},
// 라우트가 없는 경로 — 레이아웃 안에 둬서 하단 탭이 유지되고, 탭 경로(/event 등)면 그 탭이 활성으로 보인다
{
element: <ComingSoonScreen />,
Expand Down
14 changes: 14 additions & 0 deletions src/assets/icons/lockers/network-offline.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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 (
Expand All @@ -30,4 +31,4 @@ function EventsCompleteCheck() {
);
}

export default EventsCompleteCheck;
export default CompleteCheck;
132 changes: 132 additions & 0 deletions src/components/ui/ResultScreen.tsx
Original file line number Diff line number Diff line change
@@ -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(
<ScreenHeader
trailing={
<TopNavigationButton aria-label="닫기" onClick={onClose} variant="icon">
<IconClose />
</TopNavigationButton>
}
variant="normal"
/>,
);

return (
<div className="flex h-full flex-col justify-between bg-background-normal">
<div className="flex flex-col gap-6 px-5 pt-[104px]">
<div
className={`flex flex-col items-center ${ILLUSTRATION_GAP_CLASS_NAMES[illustrationGap]}`}
>
{illustration}
<div className="flex flex-col items-center gap-1 text-center">
<Typography
as="p"
color="semantic.label.normal"
variant="heading1"
weight="bold"
>
{title}
</Typography>
<Typography
as="p"
color="semantic.label.alternative"
variant="label1"
weight="regular"
>
{description}
</Typography>
</div>
</div>
{children}
</div>

<div className="shrink-0">
{/* WDS는 버튼 세로 padding을 12px(48px)로 주는데 Figma Main Action은 16px(56px)이라 맞춘다 */}
{secondaryAction ? (
<ActionArea variant="neutral">
<ActionAreaButton
buttonColor="assistive"
buttonVariant="solid"
onClick={secondaryAction.onClick}
sx={{ padding: "16px 28px" }}
variant="alternative"
>
{secondaryAction.label}
</ActionAreaButton>
<ActionAreaButton
onClick={primaryAction.onClick}
sx={{ padding: "16px 28px" }}
>
{primaryAction.label}
</ActionAreaButton>
</ActionArea>
) : (
<ActionArea>
<ActionAreaButton
onClick={primaryAction.onClick}
sx={{ paddingBlock: "16px" }}
>
{primaryAction.label}
</ActionAreaButton>
</ActionArea>
)}
{/* ActionArea 아래 padding 20px + 14px = Figma Bottom Safe Area 34px. 앱 WebView에서는
네이티브 세이프에어리어와 겹쳐서 데스크톱 프레임에서만 남긴다. */}
<div className="h-safe-bottom-extra bg-background-normal sm:h-[14px]" />
</div>
</div>
);
}

export default ResultScreen;
Loading