diff --git a/docs/conventions/coding-style.md b/docs/conventions/coding-style.md index 451dea5..c48b62b 100644 --- a/docs/conventions/coding-style.md +++ b/docs/conventions/coding-style.md @@ -41,6 +41,7 @@ 새 옵션이 필요하면 `src/app/ScreenLayoutRoute.tsx`의 `ScreenRouteHandle`에 필드를 추가하고, 그 값을 `ScreenLayout` prop으로 넘긴다. 현재 필드는 `hasBottomNav`(하단 탭 표시)와 `background`(375×812 프레임 배경 — 헤더 뒤까지 포함이라 화면 본문에서 칠할 수 없다. 신청 완료처럼 Figma가 흰 배경으로 그린 화면만 `"normal"`)다. - `ScreenLayout`은 **라우터를 모르는 prop 기반 컴포넌트**로 유지한다. 라우트 정보(`useMatches`)는 `ScreenLayoutRoute`만 읽는다. +- 헤더는 `useScreenHeader`로 `ScreenLayout`의 헤더 자리에 등록한다. 예외는 홈 하나다 — Figma 홈은 헤더 배경이 없어 배너 그림자가 헤더까지 번지는데, 헤더 자리에 두면 경계에서 그림자가 잘려 선이 생긴다. 그래서 홈만 스크롤 영역 안에 `sticky` 헤더를 직접 그린다(`wds-component-usage.md` 홈 섹션 참고). - 화면 스택을 쌓는 이동(목록→상세, 상세→신청 등)은 `navigate(to, { viewTransition: true })`·``으로 슬라이드 전환을 켠다. 뒤로가기는 react-router가 그 이동을 기억해 반대 방향으로 자동 적용하므로 `navigate(-1)`은 그대로 둔다. 브라우저 앞으로가기도 POP이라, `ScreenLayoutRoute`는 히스토리 위치(`history.state.idx`)가 줄어든 POP만 뒤로 방향으로 본다. Bottom Nav·상단 탭처럼 형제 화면을 오가는 이동과 홈으로 돌아가는 이동은 켜지 않는다(즉시 전환). 애니메이션은 `index.css`, 방향은 `ScreenLayoutRoute`가 정한다. - 라우트가 없는 경로는 레이아웃 안의 `path: "*"` 라우트(`ComingSoonScreen`)가 받는다. 하단 탭이 유지돼서 다른 화면으로 돌아갈 수 있다. 구체적인 경로가 `*`보다 항상 우선하므로 배열 순서는 신경 쓰지 않아도 된다. diff --git a/docs/conventions/component-convention.md b/docs/conventions/component-convention.md index 16c6996..0951f53 100644 --- a/docs/conventions/component-convention.md +++ b/docs/conventions/component-convention.md @@ -51,6 +51,15 @@ interface RentalItemCardProps { - 색은 `text-[#171719]`처럼 hex를 직접 박지 않는다. `src/index.css`의 `@theme` 블록에 있는 시맨틱 토큰(`text-label-normal`, `bg-background-alternative`, `border-line-solid-neutral`, `text-primary`, `bg-primary-subtle` 등)을 쓴다. 이 토큰들은 `@wanteddev/wds/global.css`가 심어둔 `--semantic-*`/`--atomic-*` CSS 변수를 그대로 별칭 연결한 것이라 다크 테마 전환도 자동으로 따라간다. - 필요한 색이 아직 토큰으로 없으면, hex를 추측해서 쓰지 말고 `get_variable_defs(fileKey, nodeId)`로 해당 노드의 실제 Figma 변수명·값을 확인한 뒤 `index.css`의 `@theme`에 새 토큰을 추가한다. `Line/Normal/Neutral`(반투명 `#70737c29`)과 `Line/Solid/Neutral`(불투명 `#eaebec`)처럼 이름이 비슷해도 값이 다른 토큰이 있으니 이름만 보고 넘겨짚지 않는다. - 컴포넌트 인스턴스가 없는 화면 배경/외곽선처럼 Figma 값이 실제로는 안 보이는 경우(예: Bottom Nav 상단 border가 바로 위 배경과 같은 색이라 안 보였던 사례)도 있다 — 이럴 땐 왜 다른 토큰으로 바꿨는지 주석으로 남긴다. + +### 크기·위치·그림자 + +- 크기·위치·간격도 `h-[261px]`처럼 px 임의값을 박지 않는다. 아래 순서로 정한다. + 1. **Tailwind 단계**: 정수 px면 단계로 쓴다. Tailwind v4는 0.25 단위(=1px) 소수 단계를 받는다 — `w-[264px]` → `w-66`, `top-[86px]` → `top-21.5`, `size-[42px]` → `size-10.5`. + 2. **이름 붙인 토큰**: 화면의 기준이 되는 크기(배너 높이 등)나 Figma 효과 스타일(그림자)은 `index.css`의 `@theme`에 이름을 붙여 쓴다 — `--spacing-notice-banner: 261px` → `h-notice-banner`, `--shadow-spread-small` → `shadow-spread-small`. 색 토큰과 같은 방식이다. + 3. **구조로 없애기**: 그림 위치·크기를 숫자로 맞춰야 하는 자리는 에셋을 Figma 배치대로 잘라 받아(카드 크기 그림, 회전이 반영된 SVG 등) `w-full`·고유 크기로 놓는다. 글자 폭처럼 남는 공간을 채우는 값은 `flex-1`·`min-w-0`으로 둔다. + 4. **예외**: 0.5px 선처럼 단계·토큰으로 나타낼 수 없는 값만 임의값으로 쓰고, 어느 Figma 값인지 주석을 남긴다. + ### 타이포그래피 - 글자 크기·굵기를 `text-xs`/`text-[17px]`/`font-semibold`처럼 Tailwind로 직접 짓지 않는다. `@wanteddev/wds`의 `Typography` 컴포넌트를 쓴다 — Figma의 이름 있는 타입 스타일(Headline 2/Bold 등)과 `variant`+`weight` 조합이 1:1로 대응한다(`node_modules/@wanteddev/wds/dist/components/typography/style.js`에서 실측 확인 가능). @@ -96,6 +105,7 @@ Figma Motion(키프레임 타임라인)이 붙은 요소는 키프레임을 손 - [ ] WDS로 확인된 요소는 전부 import로 대체했다 (raw JSX 없음) - [ ] 텍스트는 `Typography`(`variant`+`weight`)로 썼다 — 서드파티가 className만 받는 자리가 아닌 이상 `text-xs`/`text-[Npx]` 직접 사용 없음 +- [ ] 크기·위치·그림자에 px 임의값이 없다 — Tailwind 단계·`@theme` 토큰·에셋 배치로 대체했고, 남은 예외에는 주석이 있다 - [ ] Stream 고유 요소만 새 컴포넌트로 작성했다 - [ ] Props가 Figma variant를 유니온 타입으로 반영한다 - [ ] 이미지/아이콘 asset을 다운로드해 커밋했다 (만료되는 Figma URL 미참조) diff --git a/docs/conventions/wds-component-usage.md b/docs/conventions/wds-component-usage.md index 3251213..23f2f4e 100644 --- a/docs/conventions/wds-component-usage.md +++ b/docs/conventions/wds-component-usage.md @@ -584,3 +584,28 @@ A-1 본문의 `Zone Grid Row`(`2159:110823`)가 `h-px`라 `위쪽 칸/아래쪽 - **재사용(수정 없음)**: 행사 `EventsCard`, 공지 `NoticesCard`, 열린피드백 `FeedbacksCard` - **빌릴게**: `BililgeItemCard`에 `bordered` prop만 추가했다(흰 배경 위에서 테두리 `border-line-solid-neutral`). 메인 목록은 회색 배경 위라 테두리가 없다 - **아카이빙**: Figma `Search Archiving Card`(`3013:127633`)는 Stream 로컬이고 이 화면 전용이라 `features/search/components/SearchArchivingCard.tsx`로 새로 만들었다 + +## 홈 화면(`3147:146240` 2건 이하 / `3147:146297` 3건 이상 / `3562:163866` empty) 구현 중 확정된 매핑 + +| Figma | 코드 | 확인 내용 | +|---|---|---| +| Top Navigation `State=Home` (`547:31364`) | `ScreenHeader`(display) + `title`에 로고 | 게시판 헤더와 같은 Stream 로컬 컴포넌트의 상태 하나다. 그래서 display variant의 `title`이 글자 대신 요소(로고)도 받게 넓혔다 | +| `Button/Icon/Normal` + `Push Badge` (`3147:146473`) | `TopNavigationButton` + `PushBadge variant="dot"` | 알림 점은 WDS `PushBadge` 기본 크기·`top-right`가 Figma 위치와 같다 | +| `Page Indicator/Counter` (`471:13822`) | `PageCounter size="small" alternative` | 카드마다 자기 순서를 표시한다 | +| `Content Badge` (신청마감·학생회비·사물함 상태) | `ContentBadge size="small" variant="solid"` | 강조색 redOrange·orange·red·green은 `color="accent"`, 회색(미신청)은 `color="neutral"` + `semantic.label.alternative` | +| `Menu/Resource/Action Area/Trailing Content/Button` (반납 신청) | `BililgeItemCard` 재사용 | 홈은 흰 카드 안의 줄이라 `surface={false}`, 기한이 지나면 `subtitleTone="negative"` | +| 빈 상태 버튼 `대여하러 가기` / `행사 둘러보기` | `Button size="small"` | 앞은 `color="assistive" variant="solid"`, 뒤는 반납 신청과 같은 연파랑 `sx` 보정 | + +### 공지 배너는 템플릿 6종 — 그림·배경색은 공지마다가 아니라 종류별이다 + +Figma(`3562:163869`)에 일반공지·행사·제휴·사물함·간식·의견 6종이 있고 카드 구조는 같다. 다른 건 배경색·그림 크기와 위치·그라데이션 위치뿐이라 `HomeNoticeBanner`의 `NOTICE_TEMPLATES` 표 하나로 모았다. 배경색은 Figma에서도 변수가 아닌 raw hex라 `--color-notice-banner-*` 토큰으로 뒀다. 그림은 Figma에서 잘린 모양 그대로 3배로 내보낸 PNG다(`src/assets/images/home/`). + +화면설계서 기준으로 5초마다 넘어가고, 마지막 카드 오른쪽에 첫 카드 복제본을 둬서 같은 방향으로 이어진다. 카드 폭은 화면 폭 - 40(Figma 375 기준 335)이고, 카드 사이를 40px 띄워 이전·다음 카드는 화면에 보이지 않게 했다(Figma는 다음 카드가 12px 보이지만 팀 논의로 숨기기로 함). + +### 홈 헤더는 레이아웃 헤더 슬롯에 두지 않는다 + +Figma는 헤더 배경이 없어서 배너 그림자(`Shadow/Spread/Small`, 60px)가 헤더까지 번진다. 레이아웃 헤더 슬롯(`useScreenHeader`)에 두면 스크롤 영역 위 끝에서 그림자가 잘려 헤더와 배너 사이에 선이 생겨서, 홈만 스크롤 영역 안에 `sticky` 헤더를 두고 스크롤하면 배경을 깐다. 같은 이유로 배너 가로 스크롤 영역도 위아래 60px 넓혀 그림자를 자르지 않는다. + +### 빈 상태는 WDS `FallbackView`가 아니라 홈 로컬 `HomeEmptyState` + +일러스트 44px + 문구 한 줄 + 작은 버튼이라 행사 목록 Empty State 때와 같은 이유(`FallbackView`의 일러스트 폭·패딩 고정)로 맞지 않는다. 그림은 빌릴게 반납 빈 상태(`icons/bililge-empty/return-items.svg`)·행사 목록 빈 상태(`icons/events/empty-events.svg`)와 같은 도형이라 크기만 줄여 그대로 쓴다. diff --git a/src/assets/icons/home/archive-folder.svg b/src/assets/icons/home/archive-folder.svg new file mode 100644 index 0000000..24730d4 --- /dev/null +++ b/src/assets/icons/home/archive-folder.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/src/assets/icons/home/arrow-right.svg b/src/assets/icons/home/arrow-right.svg new file mode 100644 index 0000000..be36674 --- /dev/null +++ b/src/assets/icons/home/arrow-right.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/assets/icons/home/arrow-up-right.svg b/src/assets/icons/home/arrow-up-right.svg new file mode 100644 index 0000000..b40a4a2 --- /dev/null +++ b/src/assets/icons/home/arrow-up-right.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/assets/icons/home/info-fee.svg b/src/assets/icons/home/info-fee.svg new file mode 100644 index 0000000..95959b5 --- /dev/null +++ b/src/assets/icons/home/info-fee.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/src/assets/icons/home/info-feedback.svg b/src/assets/icons/home/info-feedback.svg new file mode 100644 index 0000000..83b0e02 --- /dev/null +++ b/src/assets/icons/home/info-feedback.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/src/assets/icons/home/info-locker.svg b/src/assets/icons/home/info-locker.svg new file mode 100644 index 0000000..463b864 --- /dev/null +++ b/src/assets/icons/home/info-locker.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/src/assets/icons/home/logo.svg b/src/assets/icons/home/logo.svg new file mode 100644 index 0000000..f679443 --- /dev/null +++ b/src/assets/icons/home/logo.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/src/assets/images/home/notice-event.jpg b/src/assets/images/home/notice-event.jpg new file mode 100644 index 0000000..aa472ef Binary files /dev/null and b/src/assets/images/home/notice-event.jpg differ diff --git a/src/assets/images/home/notice-feedback.jpg b/src/assets/images/home/notice-feedback.jpg new file mode 100644 index 0000000..dfc27f5 Binary files /dev/null and b/src/assets/images/home/notice-feedback.jpg differ diff --git a/src/assets/images/home/notice-general.jpg b/src/assets/images/home/notice-general.jpg new file mode 100644 index 0000000..0f9640e Binary files /dev/null and b/src/assets/images/home/notice-general.jpg differ diff --git a/src/assets/images/home/notice-locker.jpg b/src/assets/images/home/notice-locker.jpg new file mode 100644 index 0000000..2de9752 Binary files /dev/null and b/src/assets/images/home/notice-locker.jpg differ diff --git a/src/assets/images/home/notice-partnership.jpg b/src/assets/images/home/notice-partnership.jpg new file mode 100644 index 0000000..335f5dc Binary files /dev/null and b/src/assets/images/home/notice-partnership.jpg differ diff --git a/src/assets/images/home/notice-snack.jpg b/src/assets/images/home/notice-snack.jpg new file mode 100644 index 0000000..c05f099 Binary files /dev/null and b/src/assets/images/home/notice-snack.jpg differ diff --git a/src/components/ui/ScreenHeader.tsx b/src/components/ui/ScreenHeader.tsx index 696f2a9..9a26cc7 100644 --- a/src/components/ui/ScreenHeader.tsx +++ b/src/components/ui/ScreenHeader.tsx @@ -9,10 +9,13 @@ interface ScreenHeaderToggleTitle { type ScreenHeaderTitle = string | ScreenHeaderToggleTitle; -function isToggleTitle( - title: ScreenHeaderTitle, -): title is ScreenHeaderToggleTitle { - return typeof title !== "string"; +function isToggleTitle(title: unknown): title is ScreenHeaderToggleTitle { + return ( + typeof title === "object" && + title !== null && + "options" in title && + "activeIndex" in title + ); } // Figma: 게시판류 화면의 "공지 | 열린피드백" 같은 2단 탭 타이틀(nodeId 1256:81792 "Board Title"). @@ -47,7 +50,8 @@ function ScreenHeaderToggleTitle({ type ScreenHeaderProps = | { variant?: "display"; - title?: ScreenHeaderTitle; + // 홈은 글자 대신 로고를 넣는다(Figma Top Navigation `State=Home`, nodeId 547:31364) + title?: ScreenHeaderTitle | ReactNode; trailing?: ReactNode; } | { @@ -102,19 +106,20 @@ function ScreenHeader(props: ScreenHeaderProps) {
- {title !== undefined && - (isToggleTitle(title) ? ( - - ) : ( - - {title} - - ))} + {typeof title === "string" ? ( + + {title} + + ) : isToggleTitle(title) ? ( + + ) : ( + title + )}
{trailing !== undefined && (
{trailing}
diff --git a/src/features/bililge/components/BililgeItemCard.tsx b/src/features/bililge/components/BililgeItemCard.tsx index 4b48621..3ecdd01 100644 --- a/src/features/bililge/components/BililgeItemCard.tsx +++ b/src/features/bililge/components/BililgeItemCard.tsx @@ -12,6 +12,10 @@ interface BililgeItemCardProps { // 강조(SemiBold) 서체로 붙는다 — "수량 28 · "(Regular)에 이어지는 "9/29까지 반납" 부분. // 다른 호출부(메인 목록·반납 목록)는 안 넘기면 기존처럼 subtitle 전체가 Regular로 나온다. subtitleEmphasis?: string; + // 반납 기한이 지나면 부제목이 빨갛다(홈 대여 중인 물품, nodeId 3147:146331) + subtitleTone?: "normal" | "negative"; + // 홈은 흰 카드 안에 줄만 들어가서 카드 틀(배경·모서리·여백)을 끈다 + surface?: boolean; trailingControl?: "button" | "stepper"; // 흰 배경 화면 위에 놓일 때 카드 경계를 보여주는 테두리 — 검색 결과(Figma nodeId 3147:138737)가 // 이 모양이다. 회색 배경 위의 메인 목록은 테두리가 없고, stepper variant는 항상 테두리가 있다. @@ -37,6 +41,8 @@ function BililgeItemCard({ itemName, subtitle, subtitleEmphasis, + subtitleTone = "normal", + surface = true, trailingControl = "button", bordered = false, actionLabel = "대여 신청", @@ -49,7 +55,7 @@ function BililgeItemCard({ return (
@@ -65,7 +71,11 @@ function BililgeItemCard({ diff --git a/src/features/home/HomeScreen.tsx b/src/features/home/HomeScreen.tsx index 3a7fad0..c1c6a47 100644 --- a/src/features/home/HomeScreen.tsx +++ b/src/features/home/HomeScreen.tsx @@ -1,55 +1,109 @@ -import { TopNavigationButton } from "@wanteddev/wds"; -import { IconBell, IconSearch } from "@wanteddev/wds-icon"; -import { Link, useNavigate } from "react-router-dom"; +import { PushBadge, TopNavigationButton } from "@wanteddev/wds"; +import { IconBell, IconSearch, IconSetting } from "@wanteddev/wds-icon"; +import { useState } from "react"; +import { useNavigate } from "react-router-dom"; import chatbotIcon from "@/assets/icons/chat/bot.svg"; +import logo from "@/assets/icons/home/logo.svg"; import ScreenHeader from "@/components/ui/ScreenHeader"; -import { useScreenHeader } from "@/components/ui/useScreenHeader"; +import HomeAppliedEventSection from "@/features/home/components/HomeAppliedEventSection"; +import HomeApplyCard from "@/features/home/components/HomeApplyCard"; +import HomeArchiveBanner from "@/features/home/components/HomeArchiveBanner"; +import HomeInfoList from "@/features/home/components/HomeInfoList"; +import HomeNoticeBanner from "@/features/home/components/HomeNoticeBanner"; +import HomeRentalSection from "@/features/home/components/HomeRentalSection"; +import { + HOME_HAS_UNREAD_NOTIFICATION, + HOME_MOCK_DATA, +} from "@/features/home/constants/homeMock"; import { getSearchPath } from "@/features/search/constants/search"; -// 홈 화면 콘텐츠는 아직 없어서, 라우팅이 실제로 동작하는지 확인할 placeholder만 둔다. -// 챗봇 FAB(Figma nodeId 2443:178457 등, 홈 화면 variant마다 우하단에 고정)만 먼저 구현한다 — +// Figma: 홈 2건 이하 (nodeId 3147:146240), 3건 이상 (3147:146297), empty (3147:146366) +// 챗봇 FAB(Figma nodeId 2443:178457 등, 홈 화면 variant마다 우하단에 고정) — // 우측 20px·바텀 내비 위 24px 고정 위치는 Figma에서 홈 variant별로 다른 화면 높이에서도 // Bottom Nav 상단과의 간격이 항상 24px로 일정한 걸 좌표로 확인해서 얻은 값이다. function HomeScreen() { const navigate = useNavigate(); - useScreenHeader( - - navigate(getSearchPath())} - variant="icon" - > - - - - - - - } - />, - ); + const [isScrolled, setIsScrolled] = useState(false); return (
-
-

- 홈 화면은 아직 준비 중이에요 -

- - 빌릴게 화면 보기 - + {/* 스크롤 영역 자체를 flex로 두면 내용이 길 때 섹션이 줄어들어서(배너 가로 스크롤 영역은 최소 높이가 0) 안쪽에서 쌓는다 */} +
setIsScrolled(event.currentTarget.scrollTop > 0)} + > + {/* Figma: Top Navigation State=Home (nodeId 547:31364), 알림 있을 때 (3147:146473) + 홈만 헤더를 스크롤 영역 안에 둔다. Figma는 헤더 배경이 없어 배너 그림자가 헤더까지 번지는데, + 레이아웃 헤더 슬롯에 두면 그 경계에서 그림자가 잘려 선이 생긴다. 스크롤하면 배경을 깔아 + 지나가는 내용과 겹치지 않게 한다. */} +
+ + stream + + } + trailing={ + <> + + + + navigate(getSearchPath())} + variant="icon" + > + + + + + + + + + } + /> +
+
+ + {/* 배너 카드(relative)가 뒤 섹션보다 나중에 그려져 그림자가 덮지 않게 뒤 섹션도 relative로 둔다 */} +
+ {HOME_MOCK_DATA.applyCards.length > 0 && ( +
+ {HOME_MOCK_DATA.applyCards.map((card) => ( + + ))} +
+ )} +
+ +
+
+ +
+
+ +
+
+ +
+
+
); diff --git a/src/features/home/components/HomeAppliedEventSection.tsx b/src/features/home/components/HomeAppliedEventSection.tsx new file mode 100644 index 0000000..1e001da --- /dev/null +++ b/src/features/home/components/HomeAppliedEventSection.tsx @@ -0,0 +1,89 @@ +import { Button, Divider, Typography } from "@wanteddev/wds"; + +import emptyEvents from "@/assets/icons/events/empty-events.svg"; +import arrowRight from "@/assets/icons/home/arrow-right.svg"; +import HomeEmptyState from "@/features/home/components/HomeEmptyState"; +import type { HomeAppliedEvent } from "@/features/home/constants/homeMock"; + +const VISIBLE_COUNT = 2; + +// Figma: Event Applications 1건 (nodeId 3147:146275), 3건 이상 (3147:146335), empty (3562:163940) +// 제목의 건수는 Figma대로 3건 이상일 때만 붙는다. +function HomeAppliedEventSection({ events }: { events: HomeAppliedEvent[] }) { + if (events.length === 0) { + return ( + + 행사 둘러보기 + + } + illustration={} + message="관심 있는 행사를 둘러 보세요" + /> + ); + } + + const hiddenCount = events.length - VISIBLE_COUNT; + + return ( +
+ + {hiddenCount > 0 ? `신청한 행사 · ${events.length}건` : "신청한 행사"} + + {events.slice(0, VISIBLE_COUNT).map((event) => ( +
+
+ + {event.name} + + + 행사까지 D-{event.daysUntilEvent} + +
+ +
+ ))} + {hiddenCount > 0 && ( + <> + + + + )} +
+ ); +} + +export default HomeAppliedEventSection; diff --git a/src/features/home/components/HomeApplyCard.tsx b/src/features/home/components/HomeApplyCard.tsx new file mode 100644 index 0000000..706ccd2 --- /dev/null +++ b/src/features/home/components/HomeApplyCard.tsx @@ -0,0 +1,33 @@ +import { Button, ContentBadge, Typography } from "@wanteddev/wds"; + +import type { HomeApplyCard as HomeApplyCardData } from "@/features/home/constants/homeMock"; + +// Figma: Upcoming Event Card (nodeId 3147:146262), Upcoming Application Card (3491:149798) +// 행사·사물함이 같은 모양이다. 배지 옆 `행사일` 문구는 Figma에서 투명 처리돼 있어 그리지 않는다. +function HomeApplyCard({ card }: { card: HomeApplyCardData }) { + return ( +
+
+ + 신청마감 D-{card.daysUntilDeadline} + + + {card.title} + +
+ +
+ ); +} + +export default HomeApplyCard; diff --git a/src/features/home/components/HomeArchiveBanner.tsx b/src/features/home/components/HomeArchiveBanner.tsx new file mode 100644 index 0000000..9254834 --- /dev/null +++ b/src/features/home/components/HomeArchiveBanner.tsx @@ -0,0 +1,31 @@ +import { Typography } from "@wanteddev/wds"; + +import archiveFolder from "@/assets/icons/home/archive-folder.svg"; + +// Figma: Archive CTA (nodeId 3147:146288) +function HomeArchiveBanner() { + return ( +
+ + 아카이빙 둘러보기 + + + 지난 활동과 기록을 한번에 + + {/* 폴더 그림은 Figma 회전(10.15°)을 SVG에 반영해 둬서 고유 크기 그대로 놓는다 */} + +
+ ); +} + +export default HomeArchiveBanner; diff --git a/src/features/home/components/HomeEmptyState.tsx b/src/features/home/components/HomeEmptyState.tsx new file mode 100644 index 0000000..6a1ae4d --- /dev/null +++ b/src/features/home/components/HomeEmptyState.tsx @@ -0,0 +1,36 @@ +import { Typography } from "@wanteddev/wds"; +import type { ReactNode } from "react"; + +interface HomeEmptyStateProps { + illustration: ReactNode; + message: string; + action: ReactNode; +} + +// Figma: 홈 empty의 Empty State (nodeId 3562:163927, 3562:163941) — 대여·신청 행사가 같은 모양이다 +function HomeEmptyState({ + illustration, + message, + action, +}: HomeEmptyStateProps) { + return ( +
+
+ {illustration} +
+
+ + {message} + + {action} +
+
+ ); +} + +export default HomeEmptyState; diff --git a/src/features/home/components/HomeInfoList.tsx b/src/features/home/components/HomeInfoList.tsx new file mode 100644 index 0000000..da78be3 --- /dev/null +++ b/src/features/home/components/HomeInfoList.tsx @@ -0,0 +1,144 @@ +import { ContentBadge, Typography } from "@wanteddev/wds"; +import type { ReactNode } from "react"; + +import arrowRight from "@/assets/icons/home/arrow-right.svg"; +import arrowUpRight from "@/assets/icons/home/arrow-up-right.svg"; +import infoFee from "@/assets/icons/home/info-fee.svg"; +import infoFeedback from "@/assets/icons/home/info-feedback.svg"; +import infoLocker from "@/assets/icons/home/info-locker.svg"; +import type { + HomeFeeStatus, + HomeLockerStatus, + HomeMyInfo, +} from "@/features/home/constants/homeMock"; + +type BadgeColor = "orange" | "red" | "green"; + +interface InfoBadge { + label: string; + // 없으면 neutral(회색) 배지 + color?: BadgeColor; +} + +// Figma: 학생회비 납부 여부 뱃지 (nodeId 3147:146475). 미납은 배지 대신 납부 안내 줄로 바뀐다. +const FEE_BADGES: Record, InfoBadge> = { + checking: { color: "orange", label: "납부확인중" }, + "needs-check": { color: "red", label: "확인필요" }, + paid: { color: "green", label: "납부완료" }, +}; + +// Figma: 사물함 배정 상태 뱃지 (nodeId 3147:146479) +const LOCKER_BADGES: Record = { + assigned: { color: "green", label: "배정완료" }, + expired: { color: "red", label: "기간종료" }, + none: { label: "미신청" }, +}; + +function InfoBadgeView({ badge }: { badge: InfoBadge }) { + return ( + + {badge.label} + + ); +} + +interface InfoRowProps { + icon: string; + title: string; + subtitle?: string; + badge?: InfoBadge; + // 학생회비 납부하기는 앱 밖(송금)으로 나가서 ↗ 화살표다 + isExternal?: boolean; +} + +function InfoRow({ icon, title, subtitle, badge, isExternal }: InfoRowProps) { + let trailing: ReactNode = ( + + ); + if (badge) { + trailing = ( + <> + + {trailing} + + ); + } + + return ( +
+
+ +
+ + {title} + + {subtitle && ( + + {subtitle} + + )} +
+
+
{trailing}
+
+ ); +} + +// Figma: Home Info List (nodeId 3147:146284). 순서는 학생회비 → 열린피드백 → 사물함으로 고정한다. +function HomeInfoList({ myInfo }: { myInfo: HomeMyInfo }) { + const { feeStatus, feedbackCount, lockerStatus, lockerLabel } = myInfo; + + return ( +
+ {feeStatus === "unpaid" ? ( + + ) : ( + + )} + 0 ? `${feedbackCount}건` : "-"} + title="열린피드백 작성내역" + /> + +
+ ); +} + +export default HomeInfoList; diff --git a/src/features/home/components/HomeNoticeBanner.tsx b/src/features/home/components/HomeNoticeBanner.tsx new file mode 100644 index 0000000..f9bc562 --- /dev/null +++ b/src/features/home/components/HomeNoticeBanner.tsx @@ -0,0 +1,245 @@ +import { PageCounter, Typography } from "@wanteddev/wds"; +import { useEffect, useRef } from "react"; + +import noticeEvent from "@/assets/images/home/notice-event.jpg"; +import noticeFeedback from "@/assets/images/home/notice-feedback.jpg"; +import noticeGeneral from "@/assets/images/home/notice-general.jpg"; +import noticeLocker from "@/assets/images/home/notice-locker.jpg"; +import noticePartnership from "@/assets/images/home/notice-partnership.jpg"; +import noticeSnack from "@/assets/images/home/notice-snack.jpg"; +import type { + HomeNotice, + HomeNoticeTemplate, +} from "@/features/home/constants/homeMock"; +import { usePrefersReducedMotion } from "@/hooks/usePrefersReducedMotion"; + +const AUTO_SLIDE_MS = 5000; + +interface NoticeTemplateStyle { + // 그림·그라데이션을 Figma 배치대로 카드 폭(335) 크기로 합친 그림 + art: string; + // 그림 아래 글자 영역 배경. 그림 아래쪽 색과 같다 + backgroundClassName: string; +} + +// Figma: Notice Carousel (nodeId 3562:163869) — 일반공지 3562:163870, 행사 3562:163888, +// 제휴 3562:163897, 사물함 3562:163906, 간식 3562:163915, 의견·설문 3562:163879 +const NOTICE_TEMPLATES: Record = { + event: { art: noticeEvent, backgroundClassName: "bg-notice-banner-event" }, + feedback: { + art: noticeFeedback, + backgroundClassName: "bg-notice-banner-general", + }, + general: { + art: noticeGeneral, + backgroundClassName: "bg-notice-banner-general", + }, + locker: { art: noticeLocker, backgroundClassName: "bg-notice-banner-locker" }, + partnership: { + art: noticePartnership, + backgroundClassName: "bg-notice-banner-partnership", + }, + snack: { art: noticeSnack, backgroundClassName: "bg-notice-banner-snack" }, +}; + +// 공지가 하나도 없을 때 보여 주는 배너 +const DEFAULT_NOTICE: HomeNotice = { + id: "default", + subtitle: "새 소식이 생기면 여기에서 알려 드릴게요", + template: "general", + title: "stream에 오신 것을 환영해요", +}; + +interface HomeNoticeCardProps { + notice: HomeNotice; + index: number; + total: number; + // 마지막 카드 뒤에 붙는 첫 카드 복제본 — 스크린리더가 두 번 읽지 않게 숨긴다 + isClone?: boolean; +} + +function HomeNoticeCard({ + notice, + index, + total, + isClone, +}: HomeNoticeCardProps) { + const template = NOTICE_TEMPLATES[notice.template]; + + return ( +
+ {/* 카드가 넓어져도 그림은 Figma 카드 폭(335 = w-83.75) 그대로 가운데에 둔다 */} + +
+
+ + {notice.title} + + + {notice.subtitle} + +
+ {total > 1 && ( + + )} +
+
+ ); +} + +// 한 칸 거리는 실제 카드 위치에서 잰다 — 간격 값(gap-10)을 한 곳에서만 관리한다 +function getStep(scroller: HTMLElement) { + const [first, second] = scroller.children as HTMLCollectionOf; + return second.offsetLeft - first.offsetLeft; +} + +// 5초마다 다음 카드로 넘어간다. 누르고 있는 동안은 멈추고, 손을 떼거나 스크롤이 멈추면 다시 5초를 센다. +// 배너가 하나면 넘기지 않는다. +// 마지막 카드 오른쪽에 첫 카드 복제본을 두어 끝에서도 같은 방향으로 이어지고, 복제본에 멈추면 +// 진짜 첫 카드 위치로 순간 이동한다. +// 카드 폭은 화면 폭 - 40(Figma 375 기준 335)이고, 카드 사이(gap-10)도 좌우 여백 합 40이라 이전·다음 카드는 화면 밖에 있다. +// 가로 스크롤 영역이 카드 그림자(Shadow/Spread/Small, 60px)를 자르지 않게 위아래로 60px(15단계) 넓힌다. +// 넓힌 자리는 아래 섹션 밑으로 깔려서 아래 섹션 터치를 막지 않는다. +function HomeNoticeBanner({ notices }: { notices: HomeNotice[] }) { + const scrollerRef = useRef(null); + const prefersReducedMotion = usePrefersReducedMotion(); + const items = notices.length > 0 ? notices : [DEFAULT_NOTICE]; + const canSlide = items.length > 1; + + useEffect(() => { + const scroller = scrollerRef.current; + if (!scroller || !canSlide) { + return; + } + + // 복제본 자리면 진짜 첫 카드로 순간 이동한다. 공지 수가 줄어 복제본 자리에 걸린 경우도 여기서 풀린다. + const jumpIfClone = () => { + if (Math.round(scroller.scrollLeft / getStep(scroller)) >= items.length) { + scroller.scrollTo({ behavior: "instant", left: 0 }); + } + }; + + let slideTimer = 0; + let isPressing = false; + const stopSlide = () => window.clearInterval(slideTimer); + const startSlide = () => { + stopSlide(); + if (prefersReducedMotion || isPressing) { + return; + } + slideTimer = window.setInterval(() => { + jumpIfClone(); + const step = getStep(scroller); + const next = Math.round(scroller.scrollLeft / step) + 1; + scroller.scrollTo({ behavior: "smooth", left: next * step }); + }, AUTO_SLIDE_MS); + }; + + // 스크롤이 멈추면(scrollend 미지원 WebView를 위해 scroll 디바운스) 복제본을 정리하고 5초를 다시 센다. + // 트랙패드·휠처럼 누르지 않고 넘긴 경우도 여기서 다시 센다. + let settleTimer = 0; + const handleScroll = () => { + window.clearTimeout(settleTimer); + settleTimer = window.setTimeout(() => { + jumpIfClone(); + startSlide(); + }, 150); + }; + // 터치는 touch 이벤트로만 판단한다. 손가락을 옆으로 끌면 브라우저가 pointercancel을 먼저 보내서, + // pointer 이벤트로 풀면 손가락이 아직 화면에 있는데 다시 넘어가기 시작한다. + const handlePress = () => { + isPressing = true; + stopSlide(); + }; + const handleRelease = () => { + if (!isPressing) { + return; + } + isPressing = false; + startSlide(); + }; + const handlePointerPress = (event: PointerEvent) => { + if (event.pointerType !== "touch") { + handlePress(); + } + }; + const handlePointerRelease = (event: PointerEvent) => { + if (event.pointerType !== "touch") { + handleRelease(); + } + }; + + jumpIfClone(); + startSlide(); + scroller.addEventListener("scroll", handleScroll, { passive: true }); + scroller.addEventListener("pointerdown", handlePointerPress); + scroller.addEventListener("touchstart", handlePress, { passive: true }); + // 영역 밖에서 손을 떼도 받도록 window에서 듣는다 + window.addEventListener("pointerup", handlePointerRelease); + window.addEventListener("pointercancel", handlePointerRelease); + window.addEventListener("touchend", handleRelease); + window.addEventListener("touchcancel", handleRelease); + + return () => { + window.clearTimeout(settleTimer); + stopSlide(); + scroller.removeEventListener("scroll", handleScroll); + scroller.removeEventListener("pointerdown", handlePointerPress); + scroller.removeEventListener("touchstart", handlePress); + window.removeEventListener("pointerup", handlePointerRelease); + window.removeEventListener("pointercancel", handlePointerRelease); + window.removeEventListener("touchend", handleRelease); + window.removeEventListener("touchcancel", handleRelease); + }; + }, [canSlide, prefersReducedMotion, items.length]); + + return ( +
+ {items.map((notice, index) => ( + + ))} + {canSlide && ( + + )} +
+ ); +} + +export default HomeNoticeBanner; diff --git a/src/features/home/components/HomeRentalSection.tsx b/src/features/home/components/HomeRentalSection.tsx new file mode 100644 index 0000000..86abc56 --- /dev/null +++ b/src/features/home/components/HomeRentalSection.tsx @@ -0,0 +1,76 @@ +import { Button, Divider, Typography } from "@wanteddev/wds"; + +import returnItemsEmpty from "@/assets/icons/bililge-empty/return-items.svg"; +import BililgeItemCard from "@/features/bililge/components/BililgeItemCard"; +import HomeEmptyState from "@/features/home/components/HomeEmptyState"; +import type { HomeRental } from "@/features/home/constants/homeMock"; + +const VISIBLE_COUNT = 2; + +// Figma: Rental Summary 2건 (nodeId 3147:146269), 3건 이상 (3147:146326), empty (3562:163925) +function HomeRentalSection({ rentals }: { rentals: HomeRental[] }) { + if (rentals.length === 0) { + return ( + + 대여하러 가기 + + } + illustration={} + message="대여한 물품이 없어요" + /> + ); + } + + const hiddenCount = rentals.length - VISIBLE_COUNT; + + return ( +
+ + 대여 중인 물품 · {rentals.length}건 + +
+ {rentals.slice(0, VISIBLE_COUNT).map((rental) => ( + + ))} +
+ {hiddenCount > 0 && ( + <> + + + + )} +
+ ); +} + +export default HomeRentalSection; diff --git a/src/features/home/constants/homeMock.ts b/src/features/home/constants/homeMock.ts new file mode 100644 index 0000000..8c44e09 --- /dev/null +++ b/src/features/home/constants/homeMock.ts @@ -0,0 +1,182 @@ +import hairDryer from "@/assets/icons/bililge-items/hair-dryer.svg"; +import powerBank from "@/assets/icons/bililge-items/power-bank.svg"; +import umbrella from "@/assets/icons/bililge-items/umbrella.svg"; + +// 홈 API가 붙기 전까지 쓰는 목데이터다. +export const HOME_HAS_UNREAD_NOTIFICATION = true; + +// 공지 배너 템플릿 — 공지마다 그림을 따로 받지 않고 종류별 그림을 쓴다(Figma 3562:163869) +export type HomeNoticeTemplate = + | "general" + | "event" + | "partnership" + | "locker" + | "snack" + | "feedback"; + +export interface HomeNotice { + id: string; + template: HomeNoticeTemplate; + title: string; + subtitle: string; +} + +// 모집 중인 행사·사물함 신청 안내 카드 +export interface HomeApplyCard { + id: string; + title: string; + daysUntilDeadline: number; +} + +export interface HomeRental { + id: string; + name: string; + icon: string; + // 음수면 반납 기한이 지났다 + hoursUntilDue: number; +} + +export interface HomeAppliedEvent { + id: string; + name: string; + daysUntilEvent: number; +} + +export type HomeFeeStatus = "unpaid" | "checking" | "needs-check" | "paid"; + +export type HomeLockerStatus = "none" | "assigned" | "expired"; + +export interface HomeMyInfo { + feeStatus: HomeFeeStatus; + feedbackCount: number; + lockerStatus: HomeLockerStatus; + lockerLabel?: string; +} + +export interface HomeData { + notices: HomeNotice[]; + applyCards: HomeApplyCard[]; + rentals: HomeRental[]; + appliedEvents: HomeAppliedEvent[]; + myInfo: HomeMyInfo; +} + +const NOTICES: HomeNotice[] = [ + { + id: "notice-1", + subtitle: "여긴 부제목이 들어가요", + template: "general", + title: "일반공지 제목이 들어가는 자리입니다", + }, + { + id: "notice-2", + subtitle: "여긴 부제목이 들어가요", + template: "event", + title: "2026-02 슬랑제 신청 안내", + }, + { + id: "notice-3", + subtitle: "여긴 부제목이 들어가요", + template: "partnership", + title: "ECHO X 해커스토익 제휴 안내", + }, + { + id: "notice-4", + subtitle: "여긴 부제목이 들어가요", + template: "locker", + title: "2026년도 사물함 신청 안내", + }, + { + id: "notice-5", + subtitle: "여긴 부제목이 들어가요", + template: "snack", + title: "2026-02 기말고사 간식행사", + }, + { + id: "notice-6", + subtitle: "여러분의 의견을 들려 주세요", + template: "feedback", + title: "stream에 바라는 점이 있다면?", + }, +]; + +const SPORTS_DAY: HomeApplyCard = { + daysUntilDeadline: 2, + id: "sports-day", + title: "소프트웨어융합대학 체육대회", +}; + +// 상태를 바꿔 홈 3종 화면을 확인한다. +// few: 대여·신청 행사 2건 이하 (Figma 3147:146240), many: 3건 이상 (3147:146297), empty: 없음 (3147:146366) +type HomeMockState = "few" | "many" | "empty"; +const MOCK_HOME_STATE: HomeMockState = "few"; + +const HOME_DATA: Record = { + empty: { + appliedEvents: [], + applyCards: [], + myInfo: { + feedbackCount: 1, + feeStatus: "unpaid", + lockerLabel: "B-21", + lockerStatus: "assigned", + }, + notices: NOTICES, + rentals: [], + }, + few: { + appliedEvents: [ + { + daysUntilEvent: 7, + id: "opening-party", + name: "26-2 개강 파티 신청 안내", + }, + ], + applyCards: [ + SPORTS_DAY, + { daysUntilDeadline: 2, id: "locker-2026", title: "2026 사물함 신청" }, + ], + myInfo: { + feedbackCount: 0, + feeStatus: "unpaid", + lockerLabel: "B-21", + lockerStatus: "assigned", + }, + notices: NOTICES, + rentals: [ + { hoursUntilDue: 23, icon: umbrella, id: "umbrella", name: "우산" }, + { hoursUntilDue: 2, icon: hairDryer, id: "hair-dryer", name: "드라이기" }, + ], + }, + many: { + appliedEvents: [ + { daysUntilEvent: 7, id: "opening-assembly", name: "26-2학기 개강총회" }, + { + daysUntilEvent: 12, + id: "sports-day", + name: "소프트웨어융합대학 체육대회", + }, + { daysUntilEvent: 20, id: "snack-event", name: "기말고사 간식행사" }, + ], + applyCards: [SPORTS_DAY], + myInfo: { + feedbackCount: 1, + feeStatus: "checking", + lockerLabel: "B-21", + lockerStatus: "assigned", + }, + notices: NOTICES, + rentals: [ + { hoursUntilDue: -2, icon: umbrella, id: "umbrella", name: "우산" }, + { hoursUntilDue: 2, icon: hairDryer, id: "hair-dryer", name: "드라이기" }, + { + hoursUntilDue: 5, + icon: powerBank, + id: "power-bank", + name: "보조배터리", + }, + ], + }, +}; + +export const HOME_MOCK_DATA = HOME_DATA[MOCK_HOME_STATE]; diff --git a/src/index.css b/src/index.css index 14d7685..d77b65f 100644 --- a/src/index.css +++ b/src/index.css @@ -67,6 +67,25 @@ 정확히 일치한다 — 이름이 아니라 실측 hex를 기준으로 확정했다(Semantic/Primary/Normal은 #0066FF로 다른 값). */ --color-progress-fill: var(--atomic-blue-60); + + /* 홈 공지 배너 템플릿별 배경. Figma에서도 변수가 아니라 raw hex다(nodeId 3562:163869). + 의견·설문(feedback) 템플릿은 일반공지와 같은 색이다. */ + --color-notice-banner-general: #f5f9ff; + --color-notice-banner-event: #f7f3ff; + --color-notice-banner-partnership: #f4fcfc; + --color-notice-banner-locker: #fff8f4; + --color-notice-banner-snack: #fdfaec; + + /* 홈 아카이빙 둘러보기 배너 그라데이션 끝색. Figma에서도 변수가 아니라 raw 값이다(nodeId 3147:146288). */ + --color-archive-cta-fade: rgb(199 221 255 / 0.1); + + /* 크기·그림자 토큰 — component-convention.md "크기·위치·그림자" 참고 */ + /* 홈 공지 배너 카드 높이(Figma 일반공지 3562:163870) */ + --spacing-notice-banner: 261px; + /* Figma 효과 스타일 Shadow/Spread/Small */ + --shadow-spread-small: 0 0 60px rgb(23 23 23 / 0.1); + /* 챗봇 플로팅 버튼 그림자(Figma Chatbot Button) */ + --drop-shadow-chatbot-fab: 2px 2px 10px rgb(0 0 0 / 0.12); } /* iOS 노치·홈 인디케이터 자리. index.html의 viewport-fit=cover와 한 쌍이다. @@ -74,6 +93,15 @@ 인셋해서 0, 폰 브라우저·PWA(standalone)는 실제 인셋, 데스크톱·구형 기기는 0이다. 그래서 기기·실행 환경 분기를 코드에서 하지 않아도 된다. 데스크톱 프레임(sm 이상)에서 Figma 스펙대로 자리를 흉내 내려면 sm: 유틸로 따로 덮는다. */ +/* 홈 아카이빙 둘러보기 배너 배경(Figma Archive CTA 3147:146288) — 각도·멈춤 위치가 단계로 나타낼 수 없는 값이라 이름 붙인 유틸로 둔다 */ +@utility bg-archive-cta { + background-image: linear-gradient( + 167.2deg, + var(--color-background-normal) 54%, + var(--color-archive-cta-fade) 153.5% + ); +} + @utility pt-safe-top { padding-top: env(safe-area-inset-top, 0px); }