Skip to content
27 changes: 27 additions & 0 deletions docs/conventions/wds-component-usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -474,3 +474,30 @@ 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`은 결과가 없었다.

### 선택 불가 칸은 범례 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: 0.31`로 줄여 그린다. 칸 배치가 바뀌어도 미니맵을 따로 고칠 일이 없고, 검은 테두리(`Highlight Overlay`)를 가로 스크롤 비율에 그대로 맞출 수 있다. 4px 글자를 직접 쓰면 브라우저 최소 글자 크기에 걸리는 문제도 피한다. A-2처럼 화면에 다 들어오는 구역은 Figma도 미니맵을 `opacity: 0`으로 숨겨서, 코드는 스크롤이 생길 때만 미니맵을 띄우고 128px 자리는 항상 남긴다.

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

A-1 본문의 `Zone Grid Row`(`2159:110823`)가 `h-px`라 `위쪽 칸/아래쪽 칸`이 칸 묶음 아래로 흘러내려 보인다. 미니맵(`2159:110761`)과 A-2(`2159:109622`)에서는 칸 묶음 높이에 맞춰 붙어 있어서, 본문도 칸 묶음 줄 높이(122px)에 맞췄다.

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

구역 카드(`LockersSectionCard`)는 선택되면 Blue/95 배경 + Primary 테두리지만, 사물함 칸은 **Primary/Normal 배경 + 흰 SemiBold 번호**다(`2159:113315`). 구역 카드 표현을 그대로 베끼면 틀린다. 하단 `Locker Selector`는 번호를 `Label 1/Normal - Bold` + Primary/Normal로 `B-25`처럼(동 이름 + 칸 번호, 구역 번호 없음) 쓰고, 버튼 문구는 `사물함 신청하기`다.
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.
203 changes: 203 additions & 0 deletions src/features/lockers/LockersLockerSelectScreen.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,203 @@
import { TopNavigationButton } from "@wanteddev/wds";
import { IconChevronLeft, IconImage, IconReset } from "@wanteddev/wds-icon";
import type { ComponentType } from "react";
import { useCallback, useLayoutEffect, useRef, useState } from "react";
import { useLocation, useNavigate, useParams } from "react-router-dom";

import ComingSoonScreen from "@/app/ComingSoonScreen";
import sectionA1Photo from "@/assets/images/lockers/section-a-1-photo.jpg";
import ScreenHeader from "@/components/ui/ScreenHeader";
import { useScreenHeader } from "@/components/ui/useScreenHeader";
import LockersA1LockerMap from "@/features/lockers/components/LockersA1LockerMap";
import LockersA2LockerMap from "@/features/lockers/components/LockersA2LockerMap";
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_A1_PHOTO_PINS,
type LockersPhotoPin,
} from "@/features/lockers/constants/lockersLockers";

interface LockerMapProps {
selectedLockerNumber: number | null;
onSelect: (lockerNumber: number) => void;
}

interface SectionLayout {
LockerMap: ComponentType<LockerMapProps>;
photo?: { src: string; pins: LockersPhotoPin[] };
}

// 구역마다 칸 배치가 실제 공간 구조라 모양이 전부 달라서, 구역별 배치 컴포넌트를 둔다.
// 아직 Figma에 A-1·A-2만 있다.
const SECTION_LAYOUTS: Record<string, SectionLayout> = {
"A-1": {
LockerMap: LockersA1LockerMap,
photo: { pins: LOCKERS_A1_PHOTO_PINS, src: sectionA1Photo },
},
"A-2": { LockerMap: LockersA2LockerMap },
};

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

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

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

function SectionLockerSelect({
sectionId,
layout,
}: {
sectionId: string;
layout: SectionLayout;
}) {
const navigate = useNavigate();
const location = useLocation();
const [selectedLockerNumber, setSelectedLockerNumber] = useState<
number | null
>(null);
const [isPhotoOpen, setIsPhotoOpen] = useState(false);
const [viewport, setViewport] = useState<LockersLockerMinimapViewport | null>(
null,
);
const scrollRef = useRef<HTMLDivElement>(null);
const { LockerMap, photo } = layout;

// 칸 배치가 화면보다 넓을 때만 미니맵을 띄운다 — Figma도 화면에 다 들어오는 A-2는
// 미니맵을 투명하게 숨겨 자리만 남겨뒀다.
const updateViewport = useCallback(() => {
const el = scrollRef.current;
if (!el) {
return;
}
const { clientWidth, scrollLeft, scrollWidth } = el;
setViewport(
scrollWidth > clientWidth
? { size: clientWidth / scrollWidth, start: scrollLeft / scrollWidth }
: null,
);
}, []);

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

return () => observer.disconnect();
}, [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={`${sectionId}구역 사물함 선택`}
trailing={
<>
{photo && (
<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 = (
<LockerMap
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 overflow-y-auto">
{/* Figma Minimap Section — 미니맵이 없어도 128px 자리를 지켜서 칸 배치 위치가 구역마다 같다 */}
<div className="flex h-32 shrink-0 items-end justify-between px-5">
{viewport ? (
<LockersLockerMinimap viewport={viewport}>
{map}
</LockersLockerMinimap>
) : (
<div />
)}
<LockersLockerLegend />
</div>
{/* 화면 끝까지 스크롤되도록 좌우 여백을 스크롤 영역 안쪽에 둔다 */}
<div
className="scrollbar-hidden shrink-0 overflow-x-auto pb-5"
onScroll={updateViewport}
ref={scrollRef}
>
<div className="w-max px-5">{map}</div>
</div>
</div>

<LockersSelectedLockerBar
// 신청 API가 아직 없어서 누를 곳만 열어둔다
onSubmit={() => {}}
sectionName={sectionId}
selectedLockerNumber={selectedLockerNumber}
/>

{photo && (
<LockersSectionPhotoModal
onClose={() => setIsPhotoOpen(false)}
open={isPhotoOpen}
photo={photo.src}
pins={photo.pins}
sectionName={sectionId}
/>
)}
</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
104 changes: 104 additions & 0 deletions src/features/lockers/components/LockersA1LockerMap.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import { Typography } from "@wanteddev/wds";
import type { ReactNode } from "react";

import LockersLockerGrid from "@/features/lockers/components/LockersLockerGrid";
import LockersShelfLabel from "@/features/lockers/components/LockersShelfLabel";
import {
LOCKERS_A1_GROUPS,
type LockersLockerGroup,
} from "@/features/lockers/constants/lockersLockers";

interface LockersA1LockerMapProps {
selectedLockerNumber: number | null;
onSelect: (lockerNumber: number) => void;
}

// 창문·벽면처럼 고를 수 없는 자리 표시. Figma "Direction Label"·"Aisle"
function MapLabel({
children,
className,
}: {
children: ReactNode;
className: string;
}) {
return (
<div
className={`flex items-center justify-center rounded-sm bg-background-alternative ${className}`}
>
<Typography
as="p"
color="semantic.label.alternative"
// 벽면 라벨은 Figma가 한 글자씩 줄을 바꾸고 단어 사이에 빈 줄을 둔다
sx={{ textAlign: "center", whiteSpace: "pre-line" }}
variant="caption2"
weight="medium"
>
{children}
</Typography>
</div>
);
}

// Figma: Zone Area (nodeId 2159:110831 외) — 칸 묶음을 감싸는 테두리
function ZoneArea({
group,
selectedLockerNumber,
onSelect,
}: { group: LockersLockerGroup } & LockersA1LockerMapProps) {
return (
<div className="shrink-0 rounded-lg border border-line-solid-alternative p-3">
<LockersLockerGrid
group={group}
onSelect={onSelect}
selectedLockerNumber={selectedLockerNumber}
/>
</div>
);
}

// Figma: A-1구역 Locker Grid Container (nodeId 2159:110821) — 창문을 사이에 두고 벽면 세 곳에
// 칸 묶음이 붙어 있다. 실제 공간 배치라 데이터가 아니라 이 레이아웃이 갖는다(구역 평면도와 같은 방침).
// 폭이 화면보다 넓어서(약 620px) 부모가 좌우 스크롤로 보여준다.
//
// Figma의 Shelf Label은 높이가 1px로 깨져 있어서, 미니맵(2159:110761)처럼 칸 묶음 줄(122px)
// 높이에 맞춘다.
function LockersA1LockerMap({
selectedLockerNumber,
onSelect,
}: LockersA1LockerMapProps) {
const zoneProps = { onSelect, selectedLockerNumber };

return (
<div className="flex w-max flex-col items-end gap-8">
<div className="flex items-end gap-3">
<LockersShelfLabel className="h-[122px]" />
<div className="flex flex-col items-center gap-2">
<MapLabel className="w-56 py-1.5">창문</MapLabel>
<div className="flex h-[122px] items-center gap-3">
<MapLabel className="h-full px-1.5">{"왼\n쪽\n\n벽\n면"}</MapLabel>
<ZoneArea group={LOCKERS_A1_GROUPS.left} {...zoneProps} />
<ZoneArea group={LOCKERS_A1_GROUPS.middle} {...zoneProps} />
<ZoneArea group={LOCKERS_A1_GROUPS.right} {...zoneProps} />
<MapLabel className="h-full px-1.5">
{"오\n른\n쪽\n\n벽\n면"}
</MapLabel>
</div>
</div>
</div>
{/* Figma에서 복도 라벨은 560px 폭 가운데 정렬이라 칸 묶음 줄 아래 가운데에 온다 */}
<div className="w-[560px]">
<Typography
as="p"
color="semantic.label.alternative"
sx={{ textAlign: "center" }}
variant="caption1"
weight="medium"
>
복도
</Typography>
</div>
</div>
);
}

export default LockersA1LockerMap;
Loading