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
35 changes: 35 additions & 0 deletions docs/conventions/wds-component-usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -439,3 +439,38 @@ WDS에 이름이 비슷한 `ProgressIndicator`가 실제로 존재해서(`제외
### `Icon/Feedback`·`Icon/Answer`는 게시판-열린피드백 목록과 같은 로컬 아이콘

이 화면의 "피드백 내용"/"학생회 답변" 아이콘은 목록 화면의 `Icon/Question`/`Icon/Answer`와 이름만 다를 뿐 같은 자리(질문/답변 구분 아이콘)라 새로 받지 않고 기존 `src/assets/icons/feedbacks/{question,answer}.svg`를 그대로 재사용했다.

## 사물함 구역 선택 화면(`1737:218452`, `1737:218489`) 구현 중 확정된 매핑

| WDS 컴포넌트 | 확인 경로 | 비고 |
|---|---|---|
| `Content Badge/Content Badge` | 구역 카드의 혼잡도 뱃지 | `445:5656` — [문서](https://montage.wanted.co.kr/docs/components/contents/content-badge/design) |
| `Icon/Normal/Chevron Up` | 평면도 가운데 열 방향 표시 | `687:24638`, 코드 `IconChevronUp` |
| `Icon/Normal/Reset` | Top Navigation 트레일링 새로고침 | `790:38150` — "초기화를 표현합니다 / 반시계 화살표". **`IconRefresh`가 아니라 `IconReset`이다** — wds-icon에 Refresh·Reset·Rotate가 다 있어서 이름만 보고 고르면 틀린다 |

평면도의 구역 카드·호실·화장실·계단은 전부 Stream 로컬이다. 뱃지만 WDS다.

### `ContentBadge`의 `size`는 `xsmall`이다 — 빌릴게의 `small`을 베끼면 틀린다

`style.js` 실측 결과 세 사이즈가 이렇다.

| size | padding | 타이포 |
|---|---|---|
| `xsmall` | `3px 6px` | Caption 2 (11px) |
| `small` | `4px 6px` | Caption 1 (12px) |
| `medium` | `5px 8px` | Label 2 (14px) |

이 화면의 뱃지는 Figma가 `px-6 py-3` + 11px이라 **`xsmall`**이다. `BililgeRentalHistoryEntry.tsx:52`가 쓰는 `small`과 다르다 — "`Content Badge`의 `size`는 화면마다 실측해야 한다" 절의 사례가 하나 더 늘었다.

혼잡도 3단계는 `color="accent"` + `accentColor`(green/orange/red)로, 배경이 accentColor의 8% 투명도로 깔린다. **마감은 `color="neutral"`**이고 이때 배경은 `semantic.fill.normal`, 글자는 `neutralColor`가 된다(`style.js`의 color 분기 확인) — Figma의 마감 뱃지(`Fill/Normal` 배경 + `Label/Alternative` 글자)와 정확히 맞는다.

### `download_assets`는 노드 모양에 따라 `export`와 `svgAssets` 중 쓸 게 갈린다

- **단일 벡터 레이어**(화장실 `1737:218466`, 계단 `1737:218484`)는 `svgAssets` 항목이 그대로 깨끗한 아이콘이다. `export`를 쓰면 캔버스 배경(`<rect fill="#EFEFEF">`)이 붙는다
- **여러 레이어 조합**(사물함 유의사항 일러스트)은 `svgAssets`가 레이어별로 쪼개져 나와서 `export`를 써야 하고, 그때 붙는 캔버스 배경을 직접 걷어내야 한다

받은 SVG에 `fill="#EFEFEF"`인 전체 크기 `<rect>`가 맨 앞에 있으면 캔버스 배경이다.

### 구역 카드 배경이 화면 배경과 같은 색이라 라우트에 `background: "normal"`이 필요하다

카드 배경이 `Background/Normal/Alternative`(`#f7f7f8`)인데 `ScreenLayout` 기본 배경도 같은 값이라, handle을 안 주면 카드가 배경에 완전히 묻힌다(실제로 처음 렌더에서 카드가 안 보였다). Figma 화면 배경이 흰 면이라 `handle: { background: "normal" }`을 준다.
14 changes: 13 additions & 1 deletion 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 LockersSectionSelectScreen from "@/features/lockers/LockersSectionSelectScreen";
import NoticesDetailScreen from "@/features/notices/NoticesDetailScreen";
import NoticesListScreen from "@/features/notices/NoticesListScreen";

Expand Down Expand Up @@ -108,7 +109,7 @@ const routes = [
},
{
element: <LockersApplyScreen />,
// 사물함 유의사항 시트 확인용 임시 라우트 — 실제 진입점이 붙으면 화면과 함께 지운다
// 시트만 있는 화면이라 뒤에 Bottom Nav가 비쳐도 Figma(홈 위에 뜨는 시트)와 같다
path: "/lockers/apply",
},
{
Expand All @@ -121,6 +122,17 @@ const routes = [
} satisfies ScreenRouteHandle,
path: "/chat",
},
{
element: <LockersSectionSelectScreen />,
// Bottom Nav 대신 하단 고정 버튼(Action Area)이 있는 화면.
// 구역 카드 배경이 Background/Normal/Alternative(#f7f7f8)라 화면까지 같은 색이면
// 카드가 배경에 묻힌다 — Figma대로 흰 면을 깐다.
handle: {
background: "normal",
hasBottomNav: false,
} satisfies ScreenRouteHandle,
path: "/lockers/apply/sections",
},
// 라우트가 없는 경로 — 레이아웃 안에 둬서 하단 탭이 유지되고, 탭 경로(/event 등)면 그 탭이 활성으로 보인다
{ element: <ComingSoonScreen />, path: "*" },
],
Expand Down
10 changes: 10 additions & 0 deletions src/assets/icons/lockers/restroom.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
5 changes: 5 additions & 0 deletions src/assets/icons/lockers/stairs.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
63 changes: 31 additions & 32 deletions src/features/lockers/LockersApplyScreen.tsx
Original file line number Diff line number Diff line change
@@ -1,44 +1,43 @@
import { Button, Typography } from "@wanteddev/wds";
import { useState } from "react";
import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";

import LockersNoticeSheet from "@/features/lockers/components/LockersNoticeSheet";
import { IS_LOCKERS_APPLY_PERIOD } from "@/features/lockers/constants/lockersNotice";

// 임시 확인용 화면 — 시트를 띄울 진입점이 아직 없어서 둔 자리다.
// 실제 진입점(홈 "사물함 상태" 퀵링크 `1737:218284` 또는 공지 상세 CTA)이 붙으면
// 이 화면과 /lockers/apply 라우트는 지운다. docs/plans/#68-lockers-notice-sheet.md 참고.
// BottomSheet의 시트 내려가는 시간(duration-[380ms])과 맞춘다 — 다 내려간 뒤에 화면을 떠난다
const SHEET_CLOSE_MS = 380;

// Figma: 사물함 신청 전 유의사항 (nodeId 1737:218213)
// 신청 흐름의 첫 화면이다 — 들어오면 유의사항 시트부터 띄우고, 확인하면 구역 선택으로 보낸다.
// 시트를 닫으면(딤 탭) 들어온 곳으로 돌아간다.
//
// 이 화면으로 오는 진입점(홈 "사물함 상태" 퀵링크 `1737:218284` 또는 공지 상세 CTA)은
// 해당 화면을 만들 때 붙인다.
function LockersApplyScreen() {
const navigate = useNavigate();
const [isOpen, setIsOpen] = useState(false);
// 시트는 닫아도 380ms 동안 미끄러져 내려간다. 기간 여부를 open과 같은 state에서 파생시키면
// 그 사이 버튼이 비활성 문구로 바뀌는 게 보이므로, 닫을 때는 open만 끄고 이 값은 유지한다.
const [isApplyPeriod, setIsApplyPeriod] = useState(true);

const openSheet = (applyPeriod: boolean) => {
setIsApplyPeriod(applyPeriod);
setIsOpen(true);
// 처음부터 open으로 그리면 시트가 올라오는 전환 없이 바로 떠 있다. 닫힌 상태가 한 번 그려진 뒤
// 열어야 BottomSheet의 전환이 보여서 두 프레임 뒤에 연다.
useEffect(() => {
let frameId = requestAnimationFrame(() => {
frameId = requestAnimationFrame(() => setIsOpen(true));
});
return () => cancelAnimationFrame(frameId);
}, []);

const handleClose = () => {
setIsOpen(false);
setTimeout(() => navigate(-1), SHEET_CLOSE_MS);
};

return (
<div className="flex flex-1 flex-col items-center justify-center gap-3 px-5">
<Typography as="p" color="semantic.label.alternative" variant="label1">
사물함 유의사항 시트 확인용 임시 화면
</Typography>
<Button onClick={() => openSheet(true)}>신청 기간일 때 열기</Button>
<Button
color="assistive"
onClick={() => openSheet(false)}
variant="outlined"
>
신청 기간이 아닐 때 열기
</Button>

<LockersNoticeSheet
isApplyPeriod={isApplyPeriod}
onClose={() => setIsOpen(false)}
// 확인 후 이동할 사물함 구역 선택 화면(1737:218452)은 다음 이슈라 지금은 닫기만 한다
onConfirm={() => setIsOpen(false)}
open={isOpen}
/>
</div>
<LockersNoticeSheet
isApplyPeriod={IS_LOCKERS_APPLY_PERIOD}
onClose={handleClose}
onConfirm={() => navigate("/lockers/apply/sections")}
open={isOpen}
/>
);
}

Expand Down
73 changes: 73 additions & 0 deletions src/features/lockers/LockersSectionSelectScreen.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import {
ActionArea,
ActionAreaButton,
TopNavigationButton,
} from "@wanteddev/wds";
import { IconChevronLeft, IconReset } from "@wanteddev/wds-icon";
import { useState } from "react";
import { useNavigate } from "react-router-dom";

import ScreenHeader from "@/components/ui/ScreenHeader";
import { useScreenHeader } from "@/components/ui/useScreenHeader";
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,
);
const navigate = useNavigate();

useScreenHeader(
<ScreenHeader
leading={
<TopNavigationButton
aria-label="뒤로가기"
onClick={() => navigate(-1)}
variant="icon"
>
<IconChevronLeft />
</TopNavigationButton>
}
title="사물함 구역 선택"
trailing={
<TopNavigationButton
aria-label="새로고침"
// 잔여 수량 API가 붙으면 페이지를 다시 읽지 말고 그 조회만 다시 하도록 바꾼다
onClick={() => window.location.reload()}
variant="icon"
>
<IconReset />
</TopNavigationButton>
}
variant="normal"
/>,
);

return (
<div className="flex flex-1 flex-col overflow-hidden">
<div className="scrollbar-hidden flex min-h-0 flex-1 flex-col overflow-y-auto px-5 pt-1 pb-2">
<LockersFloorMap
onSelect={setSelectedSectionId}
selectedSectionId={selectedSectionId}
/>
</div>

<ActionArea>
{/* 높이 보정 이유는 wds-component-usage.md "Action Area 메인 버튼 높이" 참고 */}
<ActionAreaButton
disabled={selectedSectionId === null}
sx={{ paddingBlock: "16px" }}
>
{selectedSectionId === null
? "사물함 구역을 선택해 주세요"
: "선택 완료"}
</ActionAreaButton>
</ActionArea>
</div>
);
}

export default LockersSectionSelectScreen;
133 changes: 133 additions & 0 deletions src/features/lockers/components/LockersFloorMap.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
import { Typography } from "@wanteddev/wds";
import { IconChevronUp } from "@wanteddev/wds-icon";
import type { ReactNode } from "react";

import restroomIcon from "@/assets/icons/lockers/restroom.svg";
import stairsIcon from "@/assets/icons/lockers/stairs.svg";
import LockersSectionCard from "@/features/lockers/components/LockersSectionCard";
import {
LOCKERS_LEFT_SECTIONS,
LOCKERS_RIGHT_SECTIONS,
LOCKERS_RIGHT_SECTIONS_BELOW_STAIRS,
} from "@/features/lockers/constants/lockersSections";

interface LockersFloorMapProps {
selectedSectionId: string | null;
onSelect: (sectionId: string) => void;
}

// 호실·화장실처럼 고를 수 없는 자리. 점선 테두리에 라벨만 가운데 둔다.
function MapArea({
children,
className,
}: {
children: ReactNode;
className: string;
}) {
return (
<div
className={`flex items-center justify-center rounded-lg border border-line-solid-normal border-dashed bg-background-normal ${className}`}
>
{children}
</div>
);
}

function AreaLabel({ icon, label }: { icon?: string; label: string }) {
return (
<div className="flex items-center gap-0.5">
{icon !== undefined && (
<img alt="" className="size-6 shrink-0" src={icon} />
)}
<Typography
as="p"
color="semantic.label.assistive"
variant="caption1"
weight="medium"
>
{label}
</Typography>
</div>
);
}

// Figma: 사물함 구역 선택 Floor Map (nodeId 1737:218457)
//
// 호실·화장실·계단·복도 위치는 실제 건물 구조라 데이터로 받지 않고 여기 레이아웃이 갖는다.
// 구역 카드와 잔여 수량만 데이터에서 온다.
//
// Figma는 375×812 한 벌만 그려뒀지만 ScreenLayout은 폰이든 데스크톱(480px 컬럼)이든 높이를
// 뷰포트로 꽉 채운다. 그래서 치수를 고정하지 않고 **Figma 값을 flex 비율로** 옮겨서
// 남는 공간을 비례 배분한다 — 열 124:67:124, 왼쪽 열 186:74:186:134, 오른쪽 열 74…34:74.
// 열 사이 간격(10px)과 카드 사이 간격(왼쪽 4px·오른쪽 6px)만 Figma 값 그대로 고정이다.
// 루트에 min-h-0을 주지 않아서 카드 내용 높이보다는 줄어들지 않는다 — 320×568처럼 낮은 화면에서는
// 대신 부모(LockersSectionSelectScreen)가 세로로 스크롤한다.
function LockersFloorMap({
selectedSectionId,
onSelect,
}: LockersFloorMapProps) {
const renderCard = (section: (typeof LOCKERS_RIGHT_SECTIONS)[number]) => (
<LockersSectionCard
isSelected={section.id === selectedSectionId}
key={section.id}
onSelect={onSelect}
section={section}
/>
);

return (
<div className="flex flex-1 gap-2.5">
<div className="flex flex-[124] flex-col gap-1">
<MapArea className="flex-[186]">
<AreaLabel label="231호실" />
</MapArea>
{LOCKERS_LEFT_SECTIONS.map(renderCard)}
<MapArea className="flex-[186]">
<AreaLabel label="232호실" />
</MapArea>
<MapArea className="flex-[134]">
<AreaLabel icon={restroomIcon} label="화장실" />
</MapArea>
</div>

{/* 가운데 열은 복도다 — 카드가 없고 방향 표시만 있다.
빈 칸도 Figma 비율(위 32 / 라벨 사이 176 / 아래 330)을 flex로 옮긴다.
"정릉시장 방향"은 열보다 넓어서 Figma처럼 한 줄로 넘치게 둔다. */}
<div className="flex flex-[67] flex-col items-center">
<div className="flex-[32]" />
<div className="flex shrink-0 flex-col items-center">
<IconChevronUp className="size-6 text-label-assistive" />
<Typography
as="p"
color="semantic.label.assistive"
sx={{ whiteSpace: "nowrap" }}
variant="caption1"
weight="medium"
>
정릉시장 방향
</Typography>
</div>
<div className="flex-[176]" />
<Typography
as="p"
color="semantic.label.alternative"
variant="caption1"
weight="medium"
>
복도
</Typography>
<div className="flex-[330]" />
</div>

<div className="flex flex-[124] flex-col gap-1.5">
{LOCKERS_RIGHT_SECTIONS.map(renderCard)}
<MapArea className="flex-[34]">
<AreaLabel icon={stairsIcon} label="계단" />
</MapArea>
{LOCKERS_RIGHT_SECTIONS_BELOW_STAIRS.map(renderCard)}
</div>
</div>
);
}

export default LockersFloorMap;
Loading