Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
aacba6f
feat: 사물함 칸 선택에 쓰는 색 토큰과 이미지 추가
leegain1 Sep 27, 2026
2997c91
feat: A-1·A-2구역 사물함 칸 배치 추가
leegain1 Sep 27, 2026
e465abd
feat: 구역별 사물함 선택 화면과 실제 사진 모달 추가
leegain1 Sep 27, 2026
a7fcb85
docs: 사물함 칸 선택 화면 WDS 매핑 추가
leegain1 Sep 27, 2026
3c8383b
feat: 사물함 칸 선택 상태를 Figma 디자인대로 반영
leegain1 Sep 27, 2026
cc16ed0
docs: 사물함 칸 선택 상태 매핑 추가
leegain1 Sep 27, 2026
2cb0137
feat: 사물함 선택 화면 뒤로가기를 구역 선택 화면으로 연결
leegain1 Sep 27, 2026
71f6202
fix: 실제 사진 모달에 빠진 안내 문구 추가
leegain1 Sep 27, 2026
5bf976c
feat: 사물함 구역 상세 응답 타입과 A-1·A-2 목데이터 추가
sangrae2325 Oct 1, 2026
701f1ad
refactor: 사물함 점선 상자·회색 라벨을 공용 부품으로 추출
sangrae2325 Oct 1, 2026
5b917a3
feat: 사물함 칸 묶음을 layout 데이터로 그리고 내 사물함 칸을 표시
sangrae2325 Oct 1, 2026
f86fa60
feat: 사물함 layout JSON을 그리는 렌더러 추가
sangrae2325 Oct 1, 2026
2dfa190
refactor: 사물함 칸 선택 화면을 layout 렌더러로 그리고 구역별 배치 컴포넌트 삭제
sangrae2325 Oct 1, 2026
b046925
feat: 사물함 실제 사진 핀을 비율 좌표로 놓고 행 위치로 색을 정함
sangrae2325 Oct 1, 2026
0022b33
feat: 사물함 칸 배치를 핀치·Ctrl+휠로 확대·축소
sangrae2325 Oct 2, 2026
157ebe2
refactor: 사물함 칸 배치 코드의 불필요한 주석 정리
sangrae2325 Oct 2, 2026
f27387e
feat: 사물함 미니맵을 배치 비율에 맞추고 테두리가 확대·스크롤을 따라가게 함
sangrae2325 Oct 2, 2026
d892cfe
docs: 사물함 칸 배치 layout 렌더러·확대·미니맵 매핑 반영
sangrae2325 Oct 2, 2026
3abaa94
refactor: 사물함 사진 핀 좌표를 응답에서 빼고 프론트 상수로 관리
sangrae2325 Oct 2, 2026
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
54 changes: 54 additions & 0 deletions docs/conventions/wds-component-usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -474,3 +474,57 @@ WDS에 이름이 비슷한 `ProgressIndicator`가 실제로 존재해서(`제외
### 구역 카드 배경이 화면 배경과 같은 색이라 라우트에 `background: "normal"`이 필요하다

카드 배경이 `Background/Normal/Alternative`(`#f7f7f8`)인데 `ScreenLayout` 기본 배경도 같은 값이라, handle을 안 주면 카드가 배경에 완전히 묻힌다(실제로 처음 렌더에서 카드가 안 보였다). Figma 화면 배경이 흰 면이라 `handle: { background: "normal" }`을 준다.

## 사물함 구역별 칸 선택 화면(`2159:110753` A-1, `2159:109533` A-2, `2159:110174` A-1 실제사진) 구현 중 확정된 매핑

| WDS 컴포넌트 | 확인 경로 | 비고 |
|---|---|---|
| `Icon/Normal/Image` | A-1 Top Navigation 트레일링 사진 버튼 | `search_design_system`에서 WDS 라이브러리 `component_set`으로 확인(componentKey `ba5095aab91509506b1380975f260bdb0658567f`), 코드 `IconImage` |
| `Name=close, Thick=False` (`Icon/Normal/Close`) | 실제사진 모달 닫기 | 코드 `IconClose`를 WDS `IconButton size={24}`로 감쌌다 |
| `Icon/Normal/Reset` | Top Navigation 트레일링 새로고침 | 구역 선택 화면과 같다 — `IconReset` |
| `Action Area/Action Area` | 하단 선택 영역 | `ActionAreaButton` + `paddingBlock: "16px"` 보정(위 "Action Area 메인 버튼 높이") |

칸(`Locker Cell`)·칸 묶음(`Zone Area`)·`Shelf Label`·범례·미니맵·`Locker Selector`·실제사진 모달은 전부 Stream 로컬이다. `search_design_system`에서 `Locker Cell`은 결과가 없었다.

### 구역별 칸 배치는 서버 layout JSON을 렌더러로 그린다

구역마다 배치 컴포넌트를 두지 않고, 구역 상세 응답의 `layout`(블록 7종: `row`·`column`·`lockerGroup`·`label`·`area`·`text`·`shelfLabel`)을 `LockersLayoutRenderer`가 그린다. 타입과 A-1·A-2 목데이터는 `constants/lockersSectionDetails.ts`. 블록은 Figma 로컬 요소와 이렇게 대응한다.

| 블록 | Figma | 코드 |
|---|---|---|
| `lockerGroup` | Zone Area + Locker Grid | `LockersLockerGrid`(`bordered`면 `Line/Solid/Alternative` 테두리 상자) |
| `label` | Direction Label·Aisle | `LockersMapLabel` — 세로면 한 글자씩 줄바꿈하고 단어 사이에 빈 줄 |
| `area` | Room Label·Zone Label·Stairs Area | `LockersMapArea` — 구역 선택 평면도와 같은 점선 상자 |
| `shelfLabel` | Shelf Label | `LockersShelfLabel` |

크기는 `hug`·`fill`·px다. `fill`은 부모와 같은 축이면 남은 공간을 나누고(`flex-1`) 다른 축이면 부모에 맞춰 늘어난다(`self-stretch`). 칸 상태는 layout에 없고 `lockers`에서 `lockerNumber`로 찾는다.

### 선택 불가 칸은 범례 SVG 하나로 그린다

Figma 범례의 선택 불가 칸(`2159:110819`)은 `Label/Assistive` 바탕 + `Label/Neutral` 대각선을 합친 15px SVG다. `preserveAspectRatio="none"`이라 28px 칸에 그대로 늘려도 모서리(2.29 → 4.28px)·선 굵기가 본문 칸(radius 4)과 맞아서, 본문 칸도 같은 파일(`locker-unavailable.svg`)을 쓴다.

### 미니맵은 따로 그리지 않고 본문 배치를 `zoom`으로 줄였다

Figma 미니맵은 칸 8px·글자 3.5~4px로 하나하나 다시 그렸지만, 코드에서는 본문 칸 배치를 `zoom`으로 줄여 그린다. 배치를 두 벌 관리하지 않고, 4px 글자가 브라우저 최소 글자 크기에 걸리는 문제도 피한다.

- 높이는 Figma대로 110px 고정, 폭은 배치 비율에 맞춘다(Figma 미니맵 폭 156~208px도 내용에 맞춘 값이다). 범례 앞 남은 폭을 넘으면 더 줄인다
- 본문과 같은 1배 기준 폭으로 그려야 `fill` 상자 비율이 본문과 같다
- 확대·축소가 있어서 모든 구역에 띄운다. 검은 테두리(`Highlight Overlay`)는 보이는 영역의 가로·세로 비율을 따라가고(확대하면 작아진다), 스크롤이 없으면 숨긴다

### 칸 배치 확대·축소는 CSS `zoom`으로 한다

`useLockersPinchZoom` — 핀치·Ctrl+휠(트랙패드 핀치), 1배에서 가운데부터, 최소는 배치 전체가 화면 폭에 들어오는 배율, 최대 3배(CGV 좌석 선택 참고). `transform: scale`은 스크롤 영역 크기에 반영되지 않아 `zoom`을 쓴다. 확대하는 래퍼의 최소 폭을 `%`로 주면 배율과 상관없이 화면 폭으로 계산돼 `fill` 상자가 비율대로 커지지 않으므로 px로 준다. 배치 영역에 `touch-pan-x touch-pan-y`를 줘서 두 손가락 동작이 페이지 확대로 가지 않게 한다 — 앱 WebView(iOS·안드로이드)에서는 실기기 확인이 필요하다.

### Figma 아티팩트 — A-1 `Shelf Label` 높이가 1px이다

A-1 본문의 `Zone Grid Row`(`2159:110823`)가 `h-px`라 `위쪽 칸/아래쪽 칸`이 칸 묶음 아래로 흘러내려 보인다. 미니맵(`2159:110761`)과 A-2(`2159:109622`)에서는 칸 묶음 높이에 맞춰 붙어 있어서, 칸 묶음 높이에 맞춘다 — layout의 `shelfLabel.height`(테두리 있는 3행 묶음 122, 칸만 있는 3행 묶음 96).

### 사물함 선택 시(`2159:113197`) — 선택 상태는 구역 카드와 다르다

구역 카드(`LockersSectionCard`)는 선택되면 Blue/95 배경 + Primary 테두리지만, 사물함 칸은 **Primary/Normal 배경 + 흰 SemiBold 번호**다(`2159:113315`). 구역 카드 표현을 그대로 베끼면 틀린다. 하단 `Locker Selector`는 번호를 `Label 1/Normal - Bold` + Primary/Normal로 `B-25`처럼 쓰고, 버튼 문구는 `사물함 신청하기`다. 이름은 조합하지 않고 서버 `lockerLabel`을 그대로 쓴다.

내 사물함(`isMine`)은 Figma에 상태가 없어서 임시로 `Status/Positive` 초록 테두리를 둔다(이미 신청한 칸이라 고를 수 없다).

### 실제 사진 핀

서버는 사진 주소(`photoUrl`)만 주고 핀 좌표는 프론트가 구역별로 관리한다(`constants/lockersSectionPhotoPins.ts`, 사진 대비 0~1 비율·핀 가운데 기준). 사진을 바꾸면 좌표도 같이 고친다. 색은 칸이 묶음의 몇 번째 행인지로 정한다 — 위쪽 행일수록 진하게, 3행은 Orange/50·70·90, 5행은 50·60·70·80·90(B-2구역 실제사진 `2159:110653`). 핀 크기는 Figma 두 값(12.4·14.9px)의 중간인 14px로 고정했다. Figma의 사진 회전(1.12°)·확대 보정은 사진을 자를 때 끝내고 코드는 틀에 그대로 채운다.
10 changes: 10 additions & 0 deletions src/app/router.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import FeedbacksListScreen from "@/features/feedbacks/FeedbacksListScreen";
import FeedbacksNewScreen from "@/features/feedbacks/FeedbacksNewScreen";
import HomeScreen from "@/features/home/HomeScreen";
import LockersApplyScreen from "@/features/lockers/LockersApplyScreen";
import LockersLockerSelectScreen from "@/features/lockers/LockersLockerSelectScreen";
import LockersSectionSelectScreen from "@/features/lockers/LockersSectionSelectScreen";
import NoticesDetailScreen from "@/features/notices/NoticesDetailScreen";
import NoticesListScreen from "@/features/notices/NoticesListScreen";
Expand Down Expand Up @@ -133,6 +134,15 @@ const routes = [
} satisfies ScreenRouteHandle,
path: "/lockers/apply/sections",
},
{
element: <LockersLockerSelectScreen />,
// 구역 선택 화면과 같다 — 하단 고정 선택 영역이 있고, 칸 배경이 흰 면 위에 놓인다
handle: {
background: "normal",
hasBottomNav: false,
} satisfies ScreenRouteHandle,
path: "/lockers/apply/sections/:sectionId",
},
// 라우트가 없는 경로 — 레이아웃 안에 둬서 하단 탭이 유지되고, 탭 경로(/event 등)면 그 탭이 활성으로 보인다
{ element: <ComingSoonScreen />, path: "*" },
],
Expand Down
6 changes: 6 additions & 0 deletions src/assets/icons/lockers/locker-unavailable.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
10 changes: 10 additions & 0 deletions src/assets/icons/lockers/shelf-divider.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added src/assets/images/lockers/section-a-1-photo.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
221 changes: 221 additions & 0 deletions src/features/lockers/LockersLockerSelectScreen.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,221 @@
import { TopNavigationButton } from "@wanteddev/wds";
import { IconChevronLeft, IconImage, IconReset } from "@wanteddev/wds-icon";
import { useCallback, useLayoutEffect, useMemo, useRef, useState } from "react";
import { useLocation, useNavigate, useParams } from "react-router-dom";

import ComingSoonScreen from "@/app/ComingSoonScreen";
import ScreenHeader from "@/components/ui/ScreenHeader";
import { useScreenHeader } from "@/components/ui/useScreenHeader";
import LockersLayoutRenderer from "@/features/lockers/components/LockersLayoutRenderer";
import LockersLockerLegend from "@/features/lockers/components/LockersLockerLegend";
import LockersLockerMinimap, {
type LockersLockerMinimapViewport,
} from "@/features/lockers/components/LockersLockerMinimap";
import LockersSectionPhotoModal from "@/features/lockers/components/LockersSectionPhotoModal";
import LockersSelectedLockerBar from "@/features/lockers/components/LockersSelectedLockerBar";
import {
LOCKERS_SECTION_DETAILS,
type LockersSectionDetail,
} from "@/features/lockers/constants/lockersSectionDetails";
import { LOCKERS_SECTION_PHOTO_PINS } from "@/features/lockers/constants/lockersSectionPhotoPins";
import { useLockersPinchZoom } from "@/features/lockers/hooks/useLockersPinchZoom";

// 배치 영역 안쪽 여백(px-5, pb-5) — 미니맵에 보이는 영역을 배치 기준으로 계산할 때 뺀다
const CONTENT_PADDING = 20;

interface MinimapState {
viewport: LockersLockerMinimapViewport;
layoutWidth: number;
isScrollable: boolean;
}

function clampRatio(value: number) {
return Math.min(Math.max(value, 0), 1);
}

// Figma: A-1구역 (nodeId 2159:110753), A-2구역 (2159:109533), A-1구역 실제사진 (2159:110174)
function LockersLockerSelectScreen() {
const { sectionId = "" } = useParams();
const detail = LOCKERS_SECTION_DETAILS[sectionId];

if (detail === undefined) {
return <ComingSoonScreen />;
}

// 구역이 바뀌면 고른 칸·스크롤을 처음부터 다시 잡는다
return <SectionLockerSelect detail={detail} key={sectionId} />;
}

function SectionLockerSelect({ detail }: { detail: LockersSectionDetail }) {
const navigate = useNavigate();
const location = useLocation();
const [selectedLockerNumber, setSelectedLockerNumber] = useState<
number | null
>(null);
const [isPhotoOpen, setIsPhotoOpen] = useState(false);
const [minimap, setMinimap] = useState<MinimapState | null>(null);
const [scrollerWidth, setScrollerWidth] = useState<number>();
const scrollRef = useRef<HTMLDivElement>(null);
const contentRef = useRef<HTMLDivElement>(null);

const lockers = useMemo(
() =>
new Map(detail.lockers.map((locker) => [locker.lockerNumber, locker])),
[detail.lockers],
);
const selectedLocker =
selectedLockerNumber === null
? undefined
: lockers.get(selectedLockerNumber);

const updateViewport = useCallback(() => {
const scroller = scrollRef.current;
const content = contentRef.current;
if (!scroller || !content) {
return;
}
// 제스처 중에는 배율이 상태보다 앞서 DOM에만 반영돼 있어서 DOM 값을 읽는다
const zoom = Number(content.style.zoom) || 1;
const rect = content.getBoundingClientRect();
const layoutWidth = rect.width / zoom - CONTENT_PADDING * 2;
const layoutHeight = rect.height / zoom - CONTENT_PADDING;
const visibleLeft = scroller.scrollLeft / zoom - CONTENT_PADDING;
const visibleTop = scroller.scrollTop / zoom;
const left = clampRatio(visibleLeft / layoutWidth);
const top = clampRatio(visibleTop / layoutHeight);
const right = clampRatio(
(visibleLeft + scroller.clientWidth / zoom) / layoutWidth,
);
const bottom = clampRatio(
(visibleTop + scroller.clientHeight / zoom) / layoutHeight,
);

setScrollerWidth(scroller.clientWidth);
setMinimap({
isScrollable:
scroller.scrollWidth > scroller.clientWidth + 1 ||
scroller.scrollHeight > scroller.clientHeight + 1,
layoutWidth,
viewport: { height: bottom - top, left, top, width: right - left },
});
}, []);

useLayoutEffect(() => {
const el = scrollRef.current;
if (!el) {
return;
}
updateViewport();
const observer = new ResizeObserver(updateViewport);
observer.observe(el);

return () => observer.disconnect();
}, [updateViewport]);

const zoom = useLockersPinchZoom(scrollRef, contentRef, updateViewport);

useScreenHeader(
<ScreenHeader
leading={
<TopNavigationButton
aria-label="뒤로가기"
// 구역 선택 화면에서 들어왔으면 그 기록으로 돌아간다. 주소로 바로 들어와 앱 안의 이전
// 기록이 없으면(location.key === "default") 앱 밖으로 나가지 않게 구역 선택 화면으로 보낸다.
onClick={() =>
location.key === "default"
? navigate("/lockers/apply/sections", { replace: true })
: navigate(-1)
}
variant="icon"
>
<IconChevronLeft />
</TopNavigationButton>
}
title={`${detail.section}구역 사물함 선택`}
trailing={
<>
<TopNavigationButton
aria-label="실제 사진 보기"
onClick={() => setIsPhotoOpen(true)}
variant="icon"
>
<IconImage />
</TopNavigationButton>
<TopNavigationButton
aria-label="새로고침"
// 구역 선택 화면과 같다 — 칸 현황 API가 붙으면 그 조회만 다시 하도록 바꾼다
onClick={() => window.location.reload()}
variant="icon"
>
<IconReset />
</TopNavigationButton>
</>
}
variant="normal"
/>,
);

const map = (
<LockersLayoutRenderer
layout={detail.layout}
lockers={lockers}
onSelect={setSelectedLockerNumber}
selectedLockerNumber={selectedLockerNumber}
/>
);

return (
<div className="flex flex-1 flex-col overflow-hidden">
<div className="flex min-h-0 flex-1 flex-col gap-10">
{/* Figma Minimap Section */}
<div className="flex h-32 shrink-0 items-end gap-4 px-5">
{minimap ? (
<LockersLockerMinimap
isScrollable={minimap.isScrollable}
layoutWidth={minimap.layoutWidth}
viewport={minimap.viewport}
>
{map}
</LockersLockerMinimap>
) : (
<div className="flex-1" />
)}
<LockersLockerLegend />
</div>
{/* touch-pan: 두 손가락 동작을 페이지 확대 대신 이 영역의 확대로 받는다.
minWidth를 %가 아니라 px로 주는 이유: zoom을 걸면 %는 배율과 상관없이 화면 폭으로
계산돼서, 화면보다 좁은 구역을 확대할 때 "fill" 상자가 비율대로 커지지 않는다 */}
<div
className="scrollbar-hidden min-h-0 flex-1 touch-pan-x touch-pan-y overflow-auto"
onScroll={updateViewport}
ref={scrollRef}
>
<div
className="w-max px-5 pb-5"
ref={contentRef}
style={{ minWidth: scrollerWidth, zoom }}
>
{map}
</div>
</div>
</div>

<LockersSelectedLockerBar
// 신청 API가 아직 없어서 누를 곳만 열어둔다
onSubmit={() => {}}
lockerLabel={selectedLocker?.lockerLabel ?? null}
/>

<LockersSectionPhotoModal
layout={detail.layout}
onClose={() => setIsPhotoOpen(false)}
open={isPhotoOpen}
photoUrl={detail.photoUrl}
pins={LOCKERS_SECTION_PHOTO_PINS[detail.section] ?? []}
sectionName={detail.section}
/>
</div>
);
}

export default LockersLockerSelectScreen;
4 changes: 3 additions & 1 deletion src/features/lockers/LockersSectionSelectScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@ import LockersFloorMap from "@/features/lockers/components/LockersFloorMap";

// Figma: 사물함 구역 선택 전 (nodeId 1737:218452), 선택완료 (1737:218489)
// 새로고침(Figma Icon/Normal/Reset)은 잔여 수량 API가 없어 지금은 페이지를 다시 읽는다.
// 선택 후 이동할 화면은 아직 디자인이 없어서 지금은 아무것도 하지 않는다.
function LockersSectionSelectScreen() {
const [selectedSectionId, setSelectedSectionId] = useState<string | null>(
null,
Expand Down Expand Up @@ -59,6 +58,9 @@ function LockersSectionSelectScreen() {
{/* 높이 보정 이유는 wds-component-usage.md "Action Area 메인 버튼 높이" 참고 */}
<ActionAreaButton
disabled={selectedSectionId === null}
onClick={() =>
navigate(`/lockers/apply/sections/${selectedSectionId}`)
}
sx={{ paddingBlock: "16px" }}
>
{selectedSectionId === null
Expand Down
Loading